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 does what the router the mount was registered on would run before a route's
handler: its rate limit, then the security requirements it declared with WithSecurity
when they name a verifying scheme (one built by JWTBearer or APIKeyVerifier), then its
guards, then every Needs and Singleton provider it declares. An error from any of them is
rendered exactly as it would be for a route, which is what lets a frontend sit behind the same
authentication and the same budget as everything else. A router declared Public() exempts its
mounts from a verifying scheme named above it, while the guards they inherit still run; see
Authentication.
ui := muzak.NewRouter(muzak.WithDependencies(core.RequireSession))
ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
app.Include(ui)
A mount used to run only the guards it inherited, so a router authenticated with Needs(...)
served its files to anyone, and no quota counted a file request. To keep assets public, put them
on a router without those options. To keep a mount outside a quota, use SkipRateLimit().
A mount that inherits any guard or provider sends Cache-Control: private, no-cache on every
response, replacing one the file would have carried, so a cache shared between clients keeps
none of them.
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.
A directory is served by its index.html with or without a trailing slash, but a file only
without one. /app.js/ names a directory that is not there, so it is a path with no file behind
it like any other, rather than a second URL for the file against which every relative reference
in it resolves somewhere else.
| 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 |
AllowDotfiles | Serve files and directories whose name begins with a dot |
Since the answer depends on Accept when there is a fallback and no NotFound page, every
response of such a mount carries Vary: Accept, so a cache does not hand one to the other.
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 |
AllowDotfiles | Serve files and directories whose name begins with a dot |
AllowActiveContent | Serve HTML, SVG and other XML as ordinary documents rather than sandboxed |
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.
Two neighbours cover what neither does. A single file a handler chooses, such as an export a
user asked for, is a route that returns muzak.FileResponse, served with ranges and
conditional requests and typed by its extension as a mount's files are; see
Files, Streams and Redirects. A handler written for
net/http rather than a directory, such as pprof or a metrics endpoint, is served with
Router.Mount, behind the same rate limit, guards and providers as a route; see
Mounting net/http Handlers.
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 |
names a file followed by a slash (/app.js/) | a miss, as any path with no file behind it: 404, or from a Frontend the 404 page or the index.html fallback for a navigation |
| names a file or directory beginning with a dot, anywhere in the path | 404, with no fallback offered in its place |
| follows a symbolic link out of the mounted directory | refused |
| reaches the directory of a mount beneath this one, by any name | 404 |
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 |
| names a file that vanishes, or cannot be read, after it was found | 404 or 500, rendered by the error renderer like any other failure, problem details included, and the request counts as failed |
A directory that does not exist is reported when the application is built rather than on
the first request, unless SkipCheck asked otherwise. Two mounts at one path are a build
error: a second Static or Frontend at a path used to lose to the first without a word,
guards included.
Replacing the directory while the application serves it goes as each system lets it. On Unix the mount holds the directory it opened, which keeps nothing from renaming or removing it, and serves that directory until the application restarts, so a new build renamed into its place is served once the application is restarted with it. On Windows an open directory cannot be renamed or removed, so nothing holds it between requests: each file is reached through the directory opened by its path for that file and closed once the file is, and a new build renamed into place is served from the next request.
Dotfiles
A path with any segment starting with . (/.env, /.git/config) answers 404, as though
the file were not there, and the fallback page is not offered in its place. A build output or a
deployment directory is where a .env, a .git directory, an .htpasswd or an editor's swap
file ends up by accident, and serving one publishes credentials and source history to anyone who
guesses the name, which every scanner does. A leading /.well-known/ is the one exception,
because that is where a site publishes files meant to be found. Set AllowDotfiles for a
directory whose dotfiles are all meant to be public.
On Windows a path segment shaped like an 8.3 short name, such as /ENV~1, is refused whatever
the options say, because the filesystem opens the long name it stands for without any check
having seen that name.
What a file is served as
A file's type comes from its extension and never from its content, so a file with no
extension, or an unknown one, is application/octet-stream. Sniffing let an upload stored
without an extension be served as text/html, and an .svg served inline ran a client's markup
as script on the API's own origin.
A Static mount serves HTML, SVG, XHTML and other XML under Content-Security-Policy: sandbox,
so a browser that opens one directly shows it in an isolated origin with scripts, forms and
plugins turned off. An image referenced from a page is not affected. Set AllowActiveContent
for a static site whose scripts must run, such as a site of pages served with Index. A
Frontend mount does not sandbox, because its documents are the application itself, and a
single page application belongs there.
A file larger than 8 MiB from a filesystem whose files cannot seek is streamed, with its size as
Content-Length when known and Last-Modified, and without range support. Smaller ones are
buffered, so range and conditional requests keep working for them.
Symbolic links, and mounts inside mounts
The promise that a symbolic link cannot lead out of the directory is kept by Dir and by the
FS of an os.Root, and not by os.DirFS, which follows a link wherever it points, so a
link planted in a directory served through it serves the file it names. To serve a directory on
disk, set Dir, or pass os.OpenRoot(...).FS().
A link that stays inside the directory is followed and its target served by the mount that owns
the directory. A deployment step that links files into a served directory (cp -l, rsync -H,
a build cache) therefore publishes them under the new name, dotfiles included, unless the link
leads into the directory of a mount beneath.
When mounts nest, the most specific one answers, and a directory that has a mount of its own is
reachable only through that mount, under any name. Suppose a guarded mount at /admin serves
site/admin beside a public mount at / over site. A request that reaches the inner
directory through the outer mount gets 404, whether the path differs in case (/ADMIN/x, on
a case-insensitive filesystem), uses \ as a separator, goes through a symbolic link, or
reaches the directory by a mount path that differs from the directory's name (a guarded mount
at /x over site/staff, beside a public mount at / over site, no longer serves the same
files at /staff/...). The fallback page is not offered in its place.
The outer mount compares each directory a path passes through with the directory of every mount
beneath it by file identity, so the answer is exact on every platform and for every folding
rule (a ligature, a sharp s spelled out, a composed or decomposed accent), with no table to keep,
and hard links and bind mounts are covered because only the directory's identity matters. Where
both directories are given as Dir the relationship is worked out when the application is
built. A mount served from an os.DirFS is compared against every mount beneath it, since its
path cannot be read back, and one served from an embed.FS has no second name to defend
against.
A request the inner mount already answers is unaffected, and so is every mount with no mount beneath it. To publish the same files at two paths, mount them at both.
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.