You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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:

  1. A 404.html in the frontend's root is served with 404.
  2. Failing that, an index.html is served with 200, but only for a GET or HEAD that 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.

OptionEffect
DirThe directory holding the build, or the subdirectory within FS when both are set
FSServe from an io/fs.FS rather than from disk
FallbackName the file served with 200 for a navigation, instead of resolving it
NotFoundName the file served with 404. It takes precedence over Fallback and applies to any GET or HEAD
NoFallbackServe a plain 404 for anything with no file behind it
SkipCheckDo not verify the directory when the application is built, for one something else fills in later
AllowDotfilesServe 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.

OptionEffect
DirThe directory holding the files, or the subdirectory within FS
FSServe from an io/fs.FS, which is what an embed.FS of assets looks like
IndexServe a directory with the index.html inside it, as a web server does for a site of pages
SkipCheckDo not verify the directory when the application is built
AllowDotfilesServe files and directories whose name begins with a dot
AllowActiveContentServe 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 directorynever 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 path404, with no fallback offered in its place
follows a symbolic link out of the mounted directoryrefused
reaches the directory of a mount beneath this one, by any name404
uses a method other than GET or HEAD on a file that exists405, rather than the file
arrives before a SkipCheck directory exists500, with the reason logged
names a file that vanishes, or cannot be read, after it was found404 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.

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.

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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.