Developing and Compiling Webapps with Vite and Go
published
I’ve recently been playing around with Go (and RPC) and have stumbled upon the embed
package, which allows us to specify files and directory to be embedded (hence the name) into the final binary. You would usually use this to embed something like the db schema / migrations, but there’s also a world where this can be used for a vite bundle so that we can embed a SPA (single page app) into the final executable; neat right ?
Bundling the assets
Let’s consider that we already have a Vite application and that we have already run an npm run build
to generate its bundle and so we have a dist
folder where all our front assets are.
If we have a standard http mux, we could simply add a Handle
function. Let’s see what that would look like:
// this file could be called front_prod.go
//go:embed dist/*
var embedFS embed.FS
func Front(mux *http.ServeMux) {
staticFiles, err := fs.Sub(embedFS, "dist")
if err != nil {
log.Fatal(err)
}
fileServer := http.FileServer(http.FS(staticFiles))
mux.Handle("/", fileServer)
log.Println("Serving static files from embedded filesystem")
}
Let’s break this down: most of the magic happens on these lines:
//go:embed dist/*
var embedFS embed.FS
These lines tell go to get the dist
folder and all its subfolders (and files) into a structure that matches the standard library’s FS
interface and stores it in a variable called embedFS
. Inside the Front
function we simply target the dist
folder (with a bit of error handling ) and we add the FileServer
as a route to our mux.
Caveats
This is a simple and pretty “dumb” implementation of the “production assets”; if using something like React, you might need to write some logic to redirect the requests to the index.html
file if no matching asset is found.
What about during development ?
Well, I think having to build a Vite app every time you make a change during development is not the best DX, right ? What if I told you there was a way that you could have all the power of the Vite dev server (i.e. hot reload) from within your go app ?
Writing the Vite Proxy
This is the (mostly) the whole code we’ll need to do this:
// this file could be called front_dev.go
func createViteProxy(target string) http.Handler {
url, err := url.Parse(target)
if err != nil {
log.Fatal(err)
}
proxy := httputil.NewSingleHostReverseProxy(url)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Update the request to point to the Vite server
r.Host = url.Host
r.URL.Scheme = url.Scheme
r.URL.Host = url.Host
proxy.ServeHTTP(w, r)
})
}
func Front(mux *http.ServeMux) {
viteProxy := createViteProxy("http://localhost:5173")
mux.Handle("/", viteProxy)
log.Info("proxying requests to vite dev server")
}
Let’s break it down!
We have the same function signature for the Front
function
Remember this, it will be important later on
The magic happens with the createViteProxy
function, where we return a new http.HandlerFunc
that sends the request through a SingleHostReverseProxy
that points to the Vite dev server. This means that all requests the Front
function handles will be sent to the Vite server; this allows all the hot reload goodness that Vite provides to work out of the box.
How Do You Switch Between the Two ?
Here’s the fun part: you let the compiler do it!
On its latest versions, Go supports a feature called build constraints. These are values that you can pass your go build
command and that can influence which files the compiler will include.
When I shared the whole code file, I might have lied; there are actually three more lines you might need to have at the top of the file:
//go:build dev
//+build dev
// the rest of the code goes here
The two comments above are the two syntaxes for build constraints. These tell the compiler to only include this file if this constraint is passed to the build
function: here’s how you would run this:
go build -tags dev
Once you add the reverse condition on the “production” logic, you’re done!
you can just add a ! before the constraint you’re checking
This is why I said it would be important to name the Front
function the same in both files; if both files are in the same package, the rest of the program is completely oblivious to this switcharoo happening in dev or prod!
Should you do this ?
While this might almost look like magic, there are cases in which this might not be the ideal choice:
- If you need more complex routing that just redirecting to
index.html
While I have addressed this a bit in the related section, I just want to point this out again.
- separation of concerns and scalability
This setup will obviously not work if you want to deploy your frontend code to a dedicated service (Vercel, Netflify etc), this will obviously not work.
Still, there are some cases where you might want this:
- offline-first
Weirdly, this could work really well with an offline-first approach; download the binary, run it on your system and just browse to localhost:3000 (or any other port) and you have your whole application running, no internet required (except if you’re connecting to external services, obviously)
- simple deploy
It’s a binary.
- great DX
With this setup, you have a backend and frontend living on the same port (and/or domain), meaning you don’t need to worry about CORS, proxy rules and whatever else causes mental breakdowns to developers nowadays.