I was recently working on a project where the frontend is a Svelte SPA and the backend is a Go REST API. At a high level, this is how my project is structured:
main.go
backend/
# source files for Go REST API
frontend/
package.json
src/
# source files for Svelte SPA
Since Go is awesome, I’ve been using its embed feature to bake all of the compiled frontend files into the single output binary. This works great when building for production: compile the frontend, build the backend, done! This snapshots the frontend and backend code into a single, static binary. For iterative local development, however, I don’t want this behavior. I instead want the frontend changes to be reflected without needing to rebuild and restart the backend. In short, I want to say:
if building_for_production:
# embed frontend output directory (lock it in)
else:
# view frontend output directory (keep it open)
However, since embedding files into Go binary is a compile-time feature, you can’t really say things like “embed these files if some condition is true”.
Research Link to heading
So, I started doing some research. Googling for “go conditional embedding” led me to this relevant GitHub issue (a proposal for direct support for conditional embedding). The proposal was rejected for a couple reasons:
- Embedded files are decided at build time. To quote Ian Lance Taylor:
When we build the binary, we have to decide whether to embed the file or not. We can’t postpone that decision until the binary is run.
- There exists a workaround: use build tags! With some clever file structure and a build tag, the answer to “should these files be embedded” can essentially be pulled up into the build process.
The Workaround Link to heading
The workaround described in the proposal involves adding a few Go files to your frontend directory.
These files turn your frontend directory into a Go package that exposes two vars: one for the embed.FS
itself and an IsEmbedded
boolean indicating whether or not the data has been embedded.
This basic interface is implemented across three files:
frontend/frontend.go
- Defines the exported varsfrontend/frontend_embed.go
- Embeds the target directory and setsIsEmbedded
to true- Starts with the comment:
//go:build embed
- Only included when the
embed
tag is provided at build time - Build tags can be provided as command line flags:
go build -tags embed main.go
- Starts with the comment:
frontend/frontend_noembed.go
- Does NOT embed anything and setsIsEmbedded
to false- Starts with the comment:
//go:build !embed
- Only included when the
embed
tag is NOT provided at build time
- Starts with the comment:
It is then up to the importer of this frontend package to decide what to do with the exported information (some nuances are omitted from this example):
package main
import (
"io/fs"
"os"
"myproject/frontend"
"myproject/backend/web"
)
func main() {
// ...
var frontendFS fs.FS
if frontend.IsEmbedded {
// use the embedded frontend output files if they are there
frontendFS = frontend.Frontend
} else {
// otherwise just open the frontend output dir directly
frontendFS = os.DirFS("./frontend/output")
}
app := web.NewApplication(logger, store, frontendFS)
}
Despite working as described and solving my problem, I felt myself wanting something a bit cleaner. What I really wanted was a package that encapsulated the decision of “should this directory be embedded or opened” and exposed only a single fs.FS to the importer.
Enhancements Link to heading
So, I set about making these enhancements. To be honest, it was easier than I expected. It ended up being both a nicer interface and less code! Let’s dive in, starting with the one-var interface that this package exposes:
frontend/frontend.go Link to heading
package frontend
import "io/fs"
var Frontend fs.FS
As simple as it gets!
Just export an fs.FS
that will be initialized when the package is imported.
frontend/frontend_embed.go Link to heading
//go:build embed
package frontend
import (
"embed"
"io/fs"
)
//go:embed all:build
var frontend embed.FS
func init() {
var err error
Frontend, err = fs.Sub(frontend, "build")
if err != nil {
panic(err)
}
}
This file only gets imported when the embed
build tag is present thanks to the //go:build embed
comment at the top.
It embeds the ./frontend/build
directory which holds all of the frontend’s compiled output files.
The call to fs.Sub is in here because embed.FS
includes the embedded directory’s top-level folder by default.
Calling fs.Sub
lets us drill down one level so that the exported fs.FS
includes just the internal files and not the top-level folder.
frontend/frontend_noembed.go Link to heading
//go:build !embed
package frontend
import (
"os"
)
func init() {
Frontend = os.DirFS("./frontend/build")
}
Another easy one!
This file only gets imported when the embed
build tag is not present thanks to the //go:build !embed
comment at the top.
Upon import, the ./frontend/build
directory will be opened and ready for reading.
Any changes to the frontend will be immediately visible to readers of the exported fs.FS
(assuming the frontend is running in some sort of “watch” mode that recompiles the code if anything changes).
Note that we had to specify the full path to the ./frontend/build
directory because os.DirFS is rooted in the program’s runtime directory (where the program was executed) versus the source file’s directory (which is how the embed
package works).
main.go Link to heading
package main
import (
"myproject/frontend"
"myproject/backend/web"
)
func main() {
// ...
app := web.NewApplication(logger, store, frontend.Frontend)
}
As you can see, the caller’s code is now much cleaner: it doesn’t have to know anything about how the frontend.Frontend
FS is populated.
It just imports the frontend package and passes the exposed fs.FS
down to whatever part of the code plans to serve it.
Conclusion Link to heading
I went into this problem with some pessimism: I really didn’t think I’d be able to find a clean solution to the problem. Thankfully, I’m not the first person to consider “conditional embedding” so there was already some prior discussion and a workaround to build upon. I started with this workaround and iterated on the idea. I transformed a “good but not great” interface for handling conditional embedding into a clean, single-variable export.
Will this solution tested and verified, I now get to enjoy the best of both worlds: static binaries with embedded frontend files and a productive local development setup where frontend changes are reflected immediately. I’m very happy with it!