Embedding Vue.js Apps in Go
- Updates
- Background
- Making a Sample Application: Hacker Laws
- Making the Production Build
- Development Setup
- References
Updates⌗
- 2021-06-19 - Add a third option for managing the development setup (credit to @arran4)
Background⌗
When releasing a backend web service, adoption and usability can often be increased by also including a frontend. Traditionally, web-based frontends are often served through a separate, dedicated server (e.g. NGINX). However, this can make deployment more complex, since this requires an administrator to manage separate services in tandem.
With the release of Go’s 1.16’s embed
package, we can now include these frontend assets directly in our Go binaries, making a full-stack server deployment as simple as running a single executable file.
Making a Sample Application: Hacker Laws⌗
For this app, we’re going to make a server which will respond with a new, random hacker law when the user clicks a button. Each law will include both a name and a description.
Building the Backend⌗
Let’s define a rudimentary HTTP API that responds with a random law at the endpoint /api/v1/law
:
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net/http"
)
func main() {
var port int
flag.IntVar(&port, "port", 8080, "The port to listen on")
flag.Parse()
http.Handle("/api/v1/law", http.HandlerFunc(getRandomLaw))
log.Fatalln(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
}
type Law struct {
Name string `json:"name,omitempty"`
Definition string `json:"definition,omitempty"`
}
var HackerLaws = []Law{
{
Name: "Amdahl's Law",
Definition: "Amdahl's Law is a formula which shows the potential speedup of a computational task which can be achieved by increasing the resources of a system.",
},
{
Name: "Conway's Law",
Definition: "This law suggests that the technical boundaries of a system will reflect the structure of the organisation.",
},
{
Name: "Gall's Law",
Definition: "A complex system that works is invariably found to have evolved from a simple system that worked.",
},
}
func getRandomLaw(w http.ResponseWriter, r *http.Request) {
randomLaw := HackerLaws[rand.Intn(len(HackerLaws))]
j, err := json.Marshal(randomLaw)
if err != nil {
http.Error(w, "couldn't retrieve random hacker law", http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
io.Copy(w, bytes.NewReader(j))
}
When we run this server with go run main.go
, we can access the API from curl
to validate the output:
> curl http://localhost:8080/api/v1/law
{"name":"Gall's Law","definition":"A complex system t...}
Building the Frontend⌗
Note, all these examples use Vue 2. If you’re using Vue 3, you may have to tweak a few things
To build the Vue client, we can use the Vue CLI to bootstrap our frontend. We can do this by running the following:
vue create frontend
This will create our Vue app project layout. Next, in our main.js
file, we’ll initialize and use the axios plugin as our HTTP client:
import Vue from "vue";
import App from "./App.vue";
import axios from "axios";
import VueAxios from "vue-axios";
Vue.config.productionTip = false;
const client = axios.create({
baseURL: "/api/v1",
});
Vue.use(VueAxios, client);
new Vue({
render: (h) => h(App),
}).$mount("#app");
We’ll also update our App.vue
to fetch a random hacker law from the backend, rendering it when a user presses the button:
<template>
<div id="app">
<button type="button" @click="getLaw()">Get a new hacker law</button>
<div v-if="law != null">
<h1>{{ law.name }}</h1>
<p>{{ law.definition }}</p>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
name: "App",
components: {},
data() {
return {
law: null,
};
},
methods: {
getLaw() {
Vue.axios.get("/law").then((response) => (this.law = response.data));
},
},
};
</script>
Making the Production Build⌗
When we’re in the frontend
directory, we can run our frontend production build with the following:
yarn build
This will create a new build directory in frontend/dist
containing our production frontend assets. These are the assets that we’ll want to serve from the index of our Go server. To do this, let’s use Go’s embed
package to indicate which folder we want to embed:
// ...
//go:embed frontend/dist
var frontend embed.FS
// ...
Then inside of function main
, we’ll want our web server to serve these files at from the server root. We can do this using a few helper functions:
fs.Sub
- Returns a newfs.FS
which is a subtree of a givenfs.FS
. We can use this to strip the leadingfrontend/dist
from our embedded files.http.FS
- Converts anyfs.FS
to a format suitable forhttp.FileServer
http.FileServer
- Creates a new handler that serves the given filesystem
Putting this all together, we can serve our files from the root of the web server using the following code:
func main() {
// ...
stripped, err := fs.Sub(frontend, "frontend/dist")
if err != null {
log.Fatalln(err)
}
frontendFS := http.FileServer(http.FS(stripped))
http.Handle("/", frontendFS)
// ...
}
Now if we do our production backend build:
go build main.go
./main
We now have a simple web server that we can get a random hacker law from:
The best part is that we can deploy this binary anywhere, and all the static frontend assets will be bundled with it!
Development Setup⌗
Unfortunately, we’re not quite done yet. Although we’ve addressed how to bundle our static assets into our production build, we still need to come up with a strategy for developing our frontend and backend together. It would be annoying to have to do a yarn build
and go build
every time we wanted to make a minor code change. The Vue CLI service has a nice development server we can use with yarn serve
. This allows hot-reloading the frontend assets each time our code changes, as well as enabling tighter integration with Vue debugging tools such as Vue Devtools. However, we face a problem: now we have two separate services (the Vue development server and the Golang backend server) that need two separate ports to bind to
We might be tempted to simply update the backend server port like so:
go run main.go -port 8081
And similarly update our axios client settings like so:
const client = axios.create({
baseURL: "http://localhost:8081/api/v1",
});
However, if we do this, nothing will happen when we click the button on our frontend running at http://localhost:8080
. This is because of the web browser’s Same-Origin Policy, which prevents us from making API calls to different origins from our frontend. In Firefox, it shows up like this:
Fortunately, we have a couple options to solve this:
Option 1: Implement a CORS Middleware On Our Backend⌗
With this option, we can tell the backend server which frontend URL our app will be accessed from, which will enable it to respond with the appropriate CORS headers. A great module for this is github.com/rs/cors. We can do this like so:
func main() {
//...
// First, we define a new rudimentary CORS middleware
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:8080"},
})
// Then we wrap our original API handler to use the CORS middleware
http.Handle("/api/v1/law", corsMiddleware.Handler(http.HandlerFunc(getRandomLaw)))
//...
}
Note: This CORS policy is kept simple for this example, but wouldn’t be good for a production application. I highly recommend reading up on CORS at the Mozilla docs, as well as looking at the options supported by github.com/rs/cors
Now we can run our server like so:
go run main.go -port 8081
If we point our frontend’s axios client to http://localhost:8081/api/v1
, we can complete our API requests successfully now that our backend responds with a valid Access-Control-Allow-Origin
:
Lastly, we need a way to distinguish between development and production Vue builds, since our API client should use /api/v1/
as the base for production, but http://localhost:8081/api/v1
for development. Fortunately, Vue has the concept of environment modes. We can specify different values by creating a .env.production
containing our production config, and a .env.development
containing our development config.
Important Note: You should NEVER store secrets in these files, since they will be visible to anyone who uses your app. This is fine for non-secret information like our backend server URL, but not for secrets like API keys.
VUE_APP_API_BASE_URL=/api/v1
VUE_APP_API_BASE_URL=http://localhost:8081/api/v1
Then we can update our axios client to use it like so:
const client = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL,
});
Now our frontend will talk to the appropriate API endpoint in both development and production.
Option 2: Vue Dev Server Proxy⌗
Another option is using the Vue development server to proxy traffic to our backend. Inside of vue.config.js, we can specify a backend address to proxy to, as well as rules about which traffic should be proxied. This enables us to create a configuration which sends everything starting with /api
to our server running on http://localhost:8081
. However, the browser will think it’s only dealing with http://localhost:8080
, thereby satisfying the same-origin policy. We can do this with the following file in our frontend directory:
module.exports = {
devServer: {
proxy: {
"^/api": {
target: "http://localhost:8081",
changeOrigin: true,
},
},
},
};
Then we can set the axios client to use /api/v1
as our base URL:
const client = axios.create({
baseURL: "/api/v1",
});
If we’re running in development mode, our Vue development server will transparently proxy all axios requests to http://localhost:8081
. If we’re in production mode, our Golang server will receive the traffic and route to the correct endpoints.
Option 3: Use the Golang Server to Serve Development Files⌗
Credit goes to @arran4 for this method
Rather than using the Vue dev server to deliver our development assets, we can use our Go server. The Vue service has an option to build our assets and automatically rebuild them each time they change. This is done via the build --watch
command. First, we’ll update the package.json
to include a new watch
script:
"scripts": {
"watch": "vue-cli-service build --watch"
},
This will build to the frontend/dist
directory. Since we want our Go server to fetch the latest version from disk (and not embed them statically), we can use an os.DirFS
, an alternative implementation of fs.FS
. We can do this like so.
func main() {
//...
http.Handle("/", http.FileServer(http.FS(os.DirFS("frontend/dist"))))
}
However, we now have an issue: we want different behaviors between production and development builds. In development, we want to serve the frontend directly from disk; in production, we want to embed the assets statically and serve them from memory. While there are a couple of ways we can distinguish whether we’re in production or development mode (e.g. introduce a new CLI flag, or read some environment variable), I’m going to use build tags. Since this won’t need to be changed on-the-fly at runtime, it’s OK to make this a build-time option.
First, we’ll make two separate function implementations to get our frontend assets. For our production build:
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed frontend/dist
var embedFrontend embed.FS
func getFrontendAssets() fs.FS {
f, err := fs.Sub(embedFrontend, "frontend/dist")
if err != nil {
panic(err)
}
return f
}
And for development:
// +build !prod
package main
import (
"io/fs"
"os"
)
func getFrontendAssets() fs.FS {
return os.DirFS("frontend/dist")
}
In our main
function, now all we have to do is call getFrontendAssets
, which will be filled in with the correct implementation at build time:
func main() {
//...
frontend := getFrontendAssets()
http.Handle("/", http.FileServer(http.FS(frontend)))
//...
}
Now during development, we can start the dev server like this:
cd frontend
yarn watch
# In a separate terminal window
go run .
Then to build for production
cd frontend
yarn build
cd ..
go build -tags prod
Which Option Should I Choose?⌗
Which option you should choose will vary depending on your app’s deployment needs:
- Option 1 is more complex, but gives the most generic and flexible solution. This enables it to solve a wider variety of deployment use cases, such as serving our production frontend and backend on different URLs.
- Option 2 is the simplest, since it requires no special attention from our Go code. However, it is more specific to Vue’s development server, and may not work the same on other frontend frameworks.
- Option 3 allows us to have a more “realistic” development environment, since our app is responsible for serving frontend assets in both development and production scenarios. However, it involves using build tags, which is a more advanced technique that not every developer may be familiar with.
References⌗
- Cross-Origin Resource Sharing (CORS) - https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- Same-Origin Policy - https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
- Golang Module for CORS Middleware - https://github.com/rs/cors
- Vue Environment Modes - https://cli.vuejs.org/guide/mode-and-env.html
- Vue Development Server Config - https://cli.vuejs.org/config/
- Example Code - https://gitlab.com/hackandsla.sh/blog-examples/-/tree/master/2021-06-18-vue-with-go
Note: All example code is offered under the MIT license. This example code demonstrates both development setup options