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.

gif

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 ?

gif

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! gif

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:

While I have addressed this a bit in the related section, I just want to point this out again.

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:

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)

It’s a binary.

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.

gif