Static Files and Frontends
Two mounts, one machinery. Router.Frontend serves the output of a frontend build and
resolves a path with no file behind it; Router.Static serves a directory and lets a miss
stay a miss.
// Assets that belong to no particular route. A static mount serves what it
// finds and nothing else, so a miss here stays a miss rather than being
// answered with the application document by the frontend below.
app.Static("/static", muzak.StaticOptions{Dir: "static"})
// The built frontend is served last: every route above is matched first, so
// mounting at the root cannot shadow the API.
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
Nothing is rendered on the server and nothing is built here. Both serve files that already
exist, which is what npm run build and its equivalents produce.
Routes win
A request is matched against every registered route first and reaches a mount only when
none of them answered, so mounting a frontend at / cannot shadow an API. Middleware still
applies, and so do the guards of the router the mount was registered on, which is what lets
a frontend sit behind the same authentication as everything else.
ui := muzak.NewRouter(muzak.WithDependencies(core.RequireSession))
ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
app.Include(ui)
Mounting under a prefix works the way everything else does, through the router the mount is registered on:
ui := muzak.NewRouter()
ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
app.Include(ui, muzak.WithPrefix("/app"))
Serving a frontend
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
A request for a path with no file behind it falls back to one, resolved from what the build actually produced:
- A
404.htmlin the frontend's root is served with404. - Failing that, an
index.htmlis served with200, but only for aGETorHEADthat asks for HTML, which is what a browser navigation does.
That second rule is what a client-side router needs in order to take over. The restriction
to navigations is what keeps a missing script or stylesheet answering 404, because
handing those an HTML document only turns a missing file into a confusing parse error
somewhere further from the cause.
| Option | Effect |
|---|---|
Dir | The directory holding the build, or the subdirectory within FS when both are set |
FS | Serve from an io/fs.FS rather than from disk |
Fallback | Name the file served with 200 for a navigation, instead of resolving it |
NotFound | Name the file served with 404. It takes precedence over Fallback and applies to any GET or HEAD |
NoFallback | Serve a plain 404 for anything with no file behind it |
SkipCheck | Do not verify the directory when the application is built, for one something else fills in later |
Shipping the frontend inside the binary
//go:embed all:dist
var assets embed.FS
func main() {
app := muzak.New(muzak.AppOptions{Title: "Awesome API"})
app.Include(routers.Items())
app.Frontend("/", muzak.FrontendOptions{FS: assets, Dir: "dist"})
log.Fatal(app.RunSignals())
}
Setting both reads Dir as a subdirectory of FS, which is what the directory embed above
produces. The deployment is then one file.
Use all:dist rather than dist, or the embed skips files whose names begin with . or
_, which is exactly what several build tools emit.
Serving assets
app.Static("/static", muzak.StaticOptions{Dir: "static"})
Static is the same machinery without the part that makes a frontend work: nothing stands
in for a path with no file behind it, so a miss is a 404 and stays one.
| Option | Effect |
|---|---|
Dir | The directory holding the files, or the subdirectory within FS |
FS | Serve from an io/fs.FS, which is what an embed.FS of assets looks like |
Index | Serve a directory with the index.html inside it, as a web server does for a site of pages |
SkipCheck | Do not verify the directory when the application is built |
Index is off by default, because a mount of scripts and stylesheets has no index and
asking for a directory is a mistake worth reporting.
Reach for Static to publish assets, and for Frontend to serve an application whose
routing happens in the browser.
What both refuse
| A request that... | ...gets |
|---|---|
| asks for a directory | never a listing. A Static mount answers its index.html only when Index asked for it |
| follows a symbolic link out of the mounted directory | refused |
uses a method other than GET or HEAD on a file that exists | 405, rather than the file |
arrives before a SkipCheck directory exists | 500, with the reason logged |
A directory that does not exist is reported when the application is built rather than on
the first request, unless SkipCheck asked otherwise.
A single binary, front to back
awesome-api/
├── cmd/
│ └── main.go
├── dist/ what the frontend build wrote
│ ├── index.html
│ ├── 404.html
│ └── assets/
├── static/ assets that belong to no route
│ └── logo.svg
├── routers/
├── handlers/
├── schemas/
└── core/
app.Include(routers.Users())
app.Include(routers.Items())
app.Static("/static", muzak.StaticOptions{Dir: "static"})
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
GET /users/ reaches the API. GET /static/logo.svg reaches the asset. GET /dashboard
reaches no route and no file, so index.html is served with 200 and the browser's router
takes it from there. GET /assets/app-4f2a.js with nothing behind it answers 404, so a
stale hashed asset fails loudly instead of returning HTML.
Where to go next
Compression covers making those assets smaller, and Server Configuration covers deploying the binary that holds them.