From 4d7f4c4a405728f5211b17884366523df40e284e Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 22:14:49 +0300 Subject: [PATCH] assets: the shared hashed-asset discovery and cache policy --- assets/assets.go | 315 +++++++++++++++++++ assets/assets_test.go | 292 +++++++++++++++++ assets/testdata/static/bundle.0badc0de.mjs | 1 + assets/testdata/static/logo.svg | 1 + assets/testdata/static/main.min.0badc0de.css | 1 + 5 files changed, 610 insertions(+) create mode 100644 assets/assets.go create mode 100644 assets/assets_test.go create mode 100644 assets/testdata/static/bundle.0badc0de.mjs create mode 100644 assets/testdata/static/logo.svg create mode 100644 assets/testdata/static/main.min.0badc0de.css diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000000000000000000000000000000000000..66d4116fda470a8ee3e314fc42b0160a6b284f92 --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,315 @@ +// Package assets is the shared hashed-static-asset discovery and serving for +// the custom services of a self-hosted SourceHut instance (compare, spec, dolt, +// cover, bench, tokens). +// +// Every one of those services runs `make css`, which writes exactly one +// content-addressed main.min..css into the static tree the binary ships, +// and every one of them then hand-rolled the same four things around it: a +// regexp for the hashed name, a glob to find whichever file this build +// produced, the href the layout links, and a static handler that serves hashed +// files immutable and everything else for an hour. Six copies is six chances to +// drift, and they had: a six-hex-digit hash pattern in one service beside an +// eight-digit one in the next, an anchored per-asset regexp beside a family +// one, a missing stylesheet fatal at startup here and logged there, a directory +// listing refused in three services and published in the fourth. This package +// is the one copy. +// +// Usage, at startup: +// +// cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix) +// if err != nil { +// return nil, err +// } +// if cssHref == "" { +// log.Printf("web: no stylesheet in this binary; run `make css` before `go build`") +// } +// svc.StyleHref = cssHref // "" renders a bare page — see Resolve +// +// staticSub, err := fs.Sub(staticFS, "static") +// ... +// mux.Handle(assets.DefaultPrefix, assets.Handler(staticSub, assets.DefaultPrefix, chromeNotFound)) +// +// The filesystem is a parameter everywhere rather than an embed this package +// owns, and for two reasons pulling in opposite directions. From one side, a +// hashed asset is a build product: a checkout never has one and a release build +// always does, so a package-level embed would leave the two branches of every +// function here — asset present, asset absent — unreachable from a test. From +// the other, it is an fs.FS and not an embed.FS because dolt serves its static +// tree from disk, os.DirFS(staticDir), and has to get the same policy as the +// five services that embed theirs. +package assets + +import ( + "fmt" + "io/fs" + "net/http" + "path" + "regexp" + "strings" +) + +// DefaultPrefix is where every service of this instance mounts its static tree: +// the prefix http.StripPrefix removes before the file server sees a request, +// and the base of every asset URL Resolve builds. It is a default and not a +// constant of the package because the mount point is the caller's routing +// decision; it is here so six services do not each spell it out. +const DefaultPrefix = "/static/" + +// Cache lifetimes, the two halves of the policy CacheControl chooses between. +// +// A content-addressed name may be kept forever, because the name changes +// whenever the bytes do — that is the whole reason `make css` puts a hash in it, +// and serving such a file with anything less than a year is paying for a +// revalidation that can never find a change. +// +// An unhashed asset — a favicon, a logo — gets an hour: long enough to matter, +// short enough that a replacement is not stuck in caches until the next hash +// rotation, which for a file whose name never changes will never come. +const ( + immutableCacheControl = "public, max-age=31536000, immutable" + shortCacheControl = "public, max-age=3600" +) + +// hashedRe matches a content-addressed asset name — the main.min..css of +// `make css`, the vendored uplot.iife.min..js of bench, the bundle..js +// of compare, and whatever else is built into a static tree with a hash in its +// name tomorrow. +// +// One pattern rather than one per asset: what makes a file cacheable forever is +// the hash in its name and not which build step produced it, so an asset named +// the family's way inherits the right lifetime without an edit here. The +// donors that anchored a full name per asset (^main\.min\.[0-9a-f]{6,}\.css$) +// had to grow a second regexp for their second asset, and their two disagreed +// about the hash length within one binary. +// +// Eight hex digits is the floor because every Makefile of this instance cuts +// sha256 to eight; a shorter run of hex is more likely a version number than a +// digest, and admitting it would hand a year of immutability to a file whose +// bytes can change under the name. +// +// ".mjs" is matched alongside ".js" because a module bundle is as +// content-addressed as a script, and an extension this pattern did not know +// would quietly demote a hashed file to the hour an unhashed one gets — the +// failure is silent and shows up only as traffic. +var hashedRe = regexp.MustCompile(`\.[0-9a-f]{8,}\.(css|m?js)$`) + +// IsHashed reports whether name is content-addressed: whether the bytes behind +// it can be trusted never to change, because a new build would produce a new +// name. The argument may be a bare file name or a whole URL path; only the tail +// is inspected. +func IsHashed(name string) bool { return hashedRe.MatchString(name) } + +// CacheControl is the lifetime an asset is served with: forever for a +// content-addressed name, an hour for one whose bytes can change under it. +func CacheControl(name string) string { + if IsHashed(name) { + return immutableCacheControl + } + return shortCacheControl +} + +// Resolve globs fsys for a content-addressed asset and returns its +// site-absolute URL under urlPrefix, or "" when this build produced none. +// +// Absence is reported as "" rather than substituted with an unhashed fallback, +// and it is not an error. There is no placeholder href to invent: a link to a +// file that is not there would 404 on every page load, once per viewer, instead +// of saying what is wrong once, at startup, to whoever can fix it. +// +// Whether that is fatal is the caller's decision, and the donors disagreed — +// compare refused to start without a stylesheet, tokens and bench logged a line +// and rendered unstyled. The shared answer is the second: a service that will +// not boot without a build artefact cannot be run from a checkout, which is +// where its tests and its first bring-up happen. A caller that wants the strict +// reading writes it at its own call site, where the sentence can name the +// service and the make target. +// +// The empty string has to be *guarded* by whoever renders it, not emitted: +// resolves to the page it sits on, so an +// unguarded empty href turns every page load into two. chrome.Page already +// renders a bare page for an empty StyleHref for this reason; a service adding +// a second asset owes its own template the same {{if}}. +// +// The first match wins. `make css` guarantees there is at most one by removing +// the previous build's file before writing the new one, so two matches mean a +// stale artefact in the tree, and picking either is equally arbitrary; the +// remedy is `make clean`, not a sort order in here. +// +// The error is only ever a malformed glob, which is a mistake in the caller's +// source rather than a state of the tree — it is returned instead of panicking +// so a service reports it the way it reports its other startup failures. +func Resolve(fsys fs.FS, glob, urlPrefix string) (string, error) { + matches, err := fs.Glob(fsys, glob) + if err != nil { + return "", fmt.Errorf("assets: glob %s: %w", glob, err) + } + if len(matches) == 0 { + return "", nil + } + return NormalizePrefix(urlPrefix) + path.Base(matches[0]), nil +} + +// Lookup resolves a request path to the name of a file in fsys, reporting false +// for anything that is not one. +// +// It exists because http.FileServer answers the two "not a file" cases in ways +// these services must not. A directory becomes a *listing*: /static/ would +// publish the whole inventory of the binary — every vendored bundle and the +// hashed stylesheet name, which is a build fingerprint nothing else on the +// surface discloses — as a public, hour-cacheable page. A missing file becomes +// net/http's own `404 page not found` in text/plain, which on a surface whose +// every other answer is a page with a nav is the one dead end a viewer cannot +// get out of. +// +// It is exported because a service that already resolves its own 404 wants the +// decision without the serving, and because Handler and the file server behind +// it must agree about what exists: the lookup is a Stat on the same FS the file +// server reads. +// +// A name io/fs rejects — an empty one, a '..' element, an absolute path, the +// second slash of //static — fails the Stat and is reported the same way, which +// is the answer a traversal attempt deserves anyway. +func Lookup(fsys fs.FS, urlPrefix, urlPath string) (string, bool) { + name, ok := strings.CutPrefix(urlPath, NormalizePrefix(urlPrefix)) + if !ok { + return "", false + } + info, err := fs.Stat(fsys, name) + if err != nil || info.IsDir() { + return "", false + } + return name, true +} + +// NormalizePrefix returns urlPrefix in the one spelling Resolve, Lookup and +// Handler all agree on: site-absolute and slash-terminated, DefaultPrefix for +// the empty string. +// +// It is exported so a caller that builds an asset URL by hand — a template +// helper, a test — cannot end up with "/staticmain.min.abc.css" while the +// handler is stripping "/static/". +func NormalizePrefix(urlPrefix string) string { + if urlPrefix == "" { + return DefaultPrefix + } + if !strings.HasPrefix(urlPrefix, "/") { + urlPrefix = "/" + urlPrefix + } + if !strings.HasSuffix(urlPrefix, "/") { + urlPrefix += "/" + } + return urlPrefix +} + +// Handler serves fsys under urlPrefix with the cache policy of CacheControl. +// +// It is the one route of these surfaces that opts out of the private, no-store +// policy the rest of a logged-in page carries, and it does so in full: the +// asset's lifetime is written and the `Vary` is removed rather than left in +// place — an asset served identically to everybody but declared to vary on +// Cookie is an asset no shared cache will ever reuse, which is the whole point +// of hashing its name in the first place. +// +// The opt-out is granted per asset, once the file has been found, and it is +// *written* later still (see writer). notFound answers everything else — a +// directory, a name that is not there, a traversal attempt — and is where a +// service passes its own chrome-wrapped 404 so an asset URL typed by hand has a +// nav to get out of. A nil notFound falls back to net/http's plaintext 404. +func Handler(fsys fs.FS, urlPrefix string, notFound http.Handler) http.Handler { + prefix := NormalizePrefix(urlPrefix) + if notFound == nil { + notFound = http.HandlerFunc(http.NotFound) + } + // The file server is built here from the same fsys Lookup consults, so the + // two cannot disagree about what exists — a caller that passed one FS to the + // handler and kept another for the lookup would be serving one tree and + // answering questions about a different one. + files := http.StripPrefix(prefix, http.FileServer(http.FS(fsys))) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, ok := Lookup(fsys, prefix, r.URL.Path) + if !ok { + notFound.ServeHTTP(w, r) + return + } + + // The one type stated here, and the one header that cannot wait for + // writer: ServeContent reads the header map to decide whether it has to + // sniff, so a Content-Type stamped at WriteHeader time would arrive + // after the decision it exists to make. + // + // net/http derives the rest from mime.TypeByExtension, which is seeded + // from the *host's* mime tables (/etc/mime.types and friends) and lets + // them override Go's builtins — so a vendored script is served as + // whatever the image underneath happens to say about ".js", which on + // some is application/x-javascript and on a minimal one may be nothing + // at all, i.e. sniffed. A browser refuses to execute a module script + // whose type is not a JavaScript MIME type, so a chart would be missing + // on one deployment and present on another from the same binary. + // ServeContent leaves a Content-Type that is already set. + // + // ".mjs" is here because it is the spelling a module bundle is most + // likely to arrive under, and the one the host tables are least likely + // to know — an extension registered later than ".js" and absent from a + // minimal image is exactly the case this branch exists for. + if ext := path.Ext(name); ext == ".js" || ext == ".mjs" { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + + files.ServeHTTP(&writer{ResponseWriter: w, cacheControl: CacheControl(name)}, r) + }) +} + +// writer puts the public cache policy of an asset on the response at the moment +// the answer is committed, and not a line earlier. +// +// Writing it onto the header map before delegating is the obvious spelling and +// it is wrong, because the header map outlives the handler that filled it: a +// panic anywhere after that line is recovered by whatever middleware renders +// the 500 — a page carrying a viewer's login block — into a response that +// already says `public, max-age=3600` with the `Vary` deleted. The directives +// belong to the bytes of an asset, so they are attached where the bytes are, +// and an answer that never gets to write keeps the private directives every +// other page on the surface carries. +// +// Everything that does reach the stamp is the delegate's answer about a file +// Lookup has already found — a 200, a 304 for a conditional request, a 206 or +// the 416 of an unsatisfiable Range — and every one of those describes the same +// public bytes, so none of them is stamped conditionally. +type writer struct { + http.ResponseWriter + + cacheControl string + stamped bool +} + +func (w *writer) WriteHeader(status int) { + w.stamp() + w.ResponseWriter.WriteHeader(status) +} + +// Write covers the delegate that writes a body without a WriteHeader of its +// own: net/http commits an implicit 200 inside Write, and the header map is +// frozen from that point on. +func (w *writer) Write(b []byte) (int, error) { + w.stamp() + return w.ResponseWriter.Write(b) +} + +// Unwrap is http.ResponseController's seam. A wrapper that does not implement +// it hides the flush and the deadlines of the writer underneath from anything +// that asks for them later. +func (w *writer) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *writer) stamp() { + if w.stamped { + return + } + w.stamped = true + + // Set and Del, not Add: the middleware has already written a page's policy + // and this is the asset's opt-out from it. The Vary goes rather than being + // overwritten, for the reason Handler gives. + w.Header().Set("Cache-Control", w.cacheControl) + w.Header().Del("Vary") +} diff --git a/assets/assets_test.go b/assets/assets_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40afe93d16cbb93259425f89ab7957768d8fba46 --- /dev/null +++ b/assets/assets_test.go @@ -0,0 +1,292 @@ +package assets_test + +import ( + "embed" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-ecore/assets" +) + +// staticFS stands in for the embedded static tree of a real service: a hashed +// stylesheet, a hashed module bundle and an unhashed logo, which is exactly the +// mix the cache policy has to split. +// +//go:embed testdata/static +var staticFS embed.FS + +const cssGlob = "testdata/static/main.min.*.css" + +// staticSub is the tree a service mounts: fs.Sub of the embed, so a request for +// /static/main.min.0badc0de.css names "main.min.0badc0de.css" in it. +func staticSub(t *testing.T) fs.FS { + t.Helper() + sub, err := fs.Sub(staticFS, "testdata/static") + require.NoError(t, err) + return sub +} + +// dirFS writes the same three files to a temporary directory and returns +// os.DirFS over it — dolt's shape, whose static tree lives on disk and is +// configured rather than embedded. +func dirFS(t *testing.T) fs.FS { + t.Helper() + dir := t.TempDir() + for _, name := range []string{"main.min.0badc0de.css", "bundle.0badc0de.mjs", "logo.svg"} { + body, err := staticFS.ReadFile("testdata/static/" + name) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600)) + } + return os.DirFS(dir) +} + +func TestResolveFindsTheHashedAssetInAnEmbeddedTree(t *testing.T) { + href, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix) + require.NoError(t, err) + + assert.Equal(t, "/static/main.min.0badc0de.css", href) + assert.True(t, assets.IsHashed(href), "a hashed asset is cacheable forever") +} + +func TestResolveFindsTheHashedAssetOnDisk(t *testing.T) { + // dolt configures a directory rather than embedding one, so the glob has no + // "static/" component in it and the URL prefix is the only thing that puts + // the asset under /static/. Both spellings must produce the same href. + href, err := assets.Resolve(dirFS(t), "main.min.*.css", assets.DefaultPrefix) + require.NoError(t, err) + + assert.Equal(t, "/static/main.min.0badc0de.css", href) +} + +func TestResolveAnswersEmptyWhenTheAssetWasNeverBuilt(t *testing.T) { + // The hashed CSS is a build product, so this is the state of every checkout: + // Resolve answers "" without an error and the layout renders no at + // all rather than one pointing at a file nothing will serve. + href, err := assets.Resolve(fstest.MapFS{}, cssGlob, assets.DefaultPrefix) + require.NoError(t, err) + assert.Empty(t, href) +} + +func TestResolveTakesTheUrlPrefixAsGiven(t *testing.T) { + fsys := fstest.MapFS{ + "static/main.min.deadbeef.css": &fstest.MapFile{Data: []byte("body{}")}, + } + + for prefix, want := range map[string]string{ + "": "/static/main.min.deadbeef.css", + "/static": "/static/main.min.deadbeef.css", + "/static/": "/static/main.min.deadbeef.css", + "assets": "/assets/main.min.deadbeef.css", + "/a/b/": "/a/b/main.min.deadbeef.css", + } { + href, err := assets.Resolve(fsys, "static/main.min.*.css", prefix) + require.NoError(t, err, prefix) + assert.Equal(t, want, href, prefix) + } +} + +func TestResolveReportsAMalformedGlob(t *testing.T) { + _, err := assets.Resolve(fstest.MapFS{}, "static/[", assets.DefaultPrefix) + require.Error(t, err) + assert.Contains(t, err.Error(), "assets: glob") +} + +func TestIsHashedRecognisesTheWholeAssetFamily(t *testing.T) { + for _, name := range []string{ + "main.min.0badc0de.css", + "bundle.0badc0de.mjs", + "uplot.iife.min.0badc0dedeadbeef.js", + "/static/main.min.0badc0de.css", + } { + assert.True(t, assets.IsHashed(name), name) + } + + for _, name := range []string{ + "main.css", // the dev build, whose bytes change under the name + "main.min.abc123.css", // six hex digits is a version, not a digest + "logo.svg", // not a build product of the css/js family + "main.min.0badc0de.svg", + "main.min.0badc0de.css.map", + } { + assert.False(t, assets.IsHashed(name), name) + } +} + +func TestCacheControlSplitsOnTheHash(t *testing.T) { + // The split is the whole policy: a name that changes with the bytes may be + // kept forever, a name that does not gets an hour so a replacement is not + // stuck in caches until a rotation that will never come. + assert.Equal(t, + "public, max-age=31536000, immutable", + assets.CacheControl("main.min.0badc0de.css")) + assert.Equal(t, + "public, max-age=31536000, immutable", + assets.CacheControl("bundle.0badc0de.mjs")) + assert.Equal(t, "public, max-age=3600", assets.CacheControl("logo.svg")) + assert.Equal(t, "public, max-age=3600", assets.CacheControl("main.css")) +} + +func TestLookupRefusesWhatIsNotAFileUnderThePrefix(t *testing.T) { + fsys := staticSub(t) + + for _, urlPath := range []string{ + "/staticky/main.min.0badc0de.css", // a prefix that only looks like ours + "/static", // the mount point itself + "/static/", // the directory, i.e. a listing + "/static/../assets.go", // traversal + "/static//main.min.0badc0de.css", // an empty path element io/fs rejects + "/static/nothing.css", + } { + _, ok := assets.Lookup(fsys, assets.DefaultPrefix, urlPath) + assert.False(t, ok, urlPath) + } + + name, ok := assets.Lookup(fsys, assets.DefaultPrefix, "/static/main.min.0badc0de.css") + assert.True(t, ok) + assert.Equal(t, "main.min.0badc0de.css", name) +} + +func TestHandlerServesAHashedAssetImmutableAndDropsVary(t *testing.T) { + h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + // The Vary a page middleware would have set before the route was reached: an + // asset served identically to everybody but declared to vary on Cookie is + // one no shared cache will reuse. + w.Header().Set("Vary", "Cookie") + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) + assert.Empty(t, w.Header().Get("Vary")) + assert.Contains(t, w.Body.String(), "margin:0") +} + +func TestHandlerServesAnUnhashedAssetForAnHour(t *testing.T) { + h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/logo.svg", nil)) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "public, max-age=3600", w.Header().Get("Cache-Control")) +} + +func TestHandlerTypesAModuleScriptItself(t *testing.T) { + // The host's mime tables may not know ".mjs" at all, and a browser refuses + // to execute a module script whose type is not a JavaScript MIME type — so + // the same binary would render a working page on one image and a broken one + // on the next if this were left to mime.TypeByExtension. + h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/bundle.0badc0de.mjs", nil)) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/javascript; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) +} + +func TestHandlerServesAnOnDiskTreeTheSameWay(t *testing.T) { + h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) +} + +func TestHandlerNeverPublishesAListing(t *testing.T) { + // /static/ under http.FileServer is the inventory of the binary — every + // vendored bundle and the hashed stylesheet name, which is a build + // fingerprint nothing else on the surface discloses. + h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/", nil)) + + require.Equal(t, http.StatusNotFound, w.Code) + assert.NotContains(t, w.Body.String(), "main.min.0badc0de.css") +} + +func TestHandlerDelegatesEverythingElseToNotFound(t *testing.T) { + // This is where a service passes its chrome-wrapped 404, so an asset URL + // typed by hand answers with a page that has a nav to get out of. + notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("")) + }) + h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil)) + + require.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "the chrome 404") +} + +func TestAnAnswerThatIsNotAnAssetKeepsThePagePolicy(t *testing.T) { + // The reason the cache directives are stamped at commit time rather than + // before delegating: the header map outlives the decision. A 404 rendered as + // a page — with a viewer's login block in it — must not inherit the public, + // hour-long policy of the asset that was never served. + notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound) + + w := httptest.NewRecorder() + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Vary", "Cookie") + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil)) + + require.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "private, no-store", w.Header().Get("Cache-Control")) + assert.Equal(t, "Cookie", w.Header().Get("Vary")) +} + +func TestHandlerStampsAConditionalRequestToo(t *testing.T) { + // A 304 describes the same public bytes as the 200 it replaces, so it + // carries the same policy — a cache that revalidated would otherwise lose + // the lifetime it revalidated for. + // + // The on-disk tree and not the embedded one: files in an embed.FS have a + // zero modification time, so net/http emits no Last-Modified for them and a + // conditional request is unanswerable in the first place. + h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) + require.Equal(t, http.StatusOK, w.Code) + lastModified := w.Header().Get("Last-Modified") + require.NotEmpty(t, lastModified) + + r := httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil) + r.Header.Set("If-Modified-Since", lastModified) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusNotModified, w.Code) + assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) +} + +func TestNormalizePrefixIsWhatKeepsTheHrefAndTheMountTogether(t *testing.T) { + for given, want := range map[string]string{ + "": "/static/", + "/static/": "/static/", + "/static": "/static/", + "static": "/static/", + "assets/": "/assets/", + } { + assert.Equal(t, want, assets.NormalizePrefix(given), given) + } +} diff --git a/assets/testdata/static/bundle.0badc0de.mjs b/assets/testdata/static/bundle.0badc0de.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d6ca3258ec5fff6660a1013a721e1070ebbe3c43 --- /dev/null +++ b/assets/testdata/static/bundle.0badc0de.mjs @@ -0,0 +1 @@ +export const ok = 1; diff --git a/assets/testdata/static/logo.svg b/assets/testdata/static/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..f6af1e9f1cb1fd3f1aebaf6ade2021ceeaa6fad2 --- /dev/null +++ b/assets/testdata/static/logo.svg @@ -0,0 +1 @@ + diff --git a/assets/testdata/static/main.min.0badc0de.css b/assets/testdata/static/main.min.0badc0de.css new file mode 100644 index 0000000000000000000000000000000000000000..9a7bd6df915d3a208d5c631f81c400eb20e60253 --- /dev/null +++ b/assets/testdata/static/main.min.0badc0de.css @@ -0,0 +1 @@ +body{margin:0}