Hashets (a portmanteau of 'hash' and 'assets') is a utility for handling cache busting of static assets. It works by adding the hash of the file's contents to the file name.
- ⚡ Three options:
- Either generate files with hashed names before compiling,
- use
hashets.HashToDirorhashets.HashToTempDirat runtime, - or create a
hashets.FSWrapperwhich translates requests for hashed file names to their original names.
- 🧒 Easy integration into templates by using a map of file names to hashed file names
- 📦 Support for
fs.FS - 🏖 Hassle-free versioning, that only causes refetching of files when their contents change (vs.
?v=1.2.3)
First impressions matter, so here are some examples of how to use hashets.
This method is for you, if:
- 🧒 You want the easiest solution of all
- 🤏 Have small assets, or you don't mind if your application takes a few milliseconds longer to start
- 🕚 You know your assets at runtime
- 🕵 You need cache busting during development and not just in production
hashets.WrapFS simply wraps an fs.FS, calculates the hashes of all its files,
and translates requests for hashed file names to their original names:
Add a static.go to your static directory:
static
├── file_to_hash.ext
└── static.go
package static
import (
"embed"
"github.com/mavolin/hashets/hashets"
)
//go:embed file_to_hash.ext
var assets embed.FS
var (
FS *hashets.FSWrapper
FileNames hashets.Map
)
func init() {
var err error
FS, FileNames, err = hashets.WrapFS(assets, hashets.Options{})
if err != nil {
panic(err)
}
}FS now translates requests for FS.Open("file_to_hash_generatedHash.ext") to assets.Open("file_to_hash.ext").
Additionally, FileNames maps all original file names to their hashed equivalents:
var FileNames = hashets.Map{
"file_to_hash.ext": "file_to_hash_generatedHash.ext",
}Use this map in your templates to generate links to your assets:
<link rel="stylesheet" href="/static/{{ .FileNames.Get "file_to_hash.ext" }}">Then simply serve FS under /static:
http.Handle("/static/", http.FileServer(http.FS(FS)))Of course, instead of an embed.FS, you can also use any other fs.FS implementation, such as os.DirFS, etc.
This method is for you, if:
- 📏 You have larger assets and need lightning fast startup times
- 🕑 You know your assets at compile time
- 🕵 You need cache busting during development and not just in production
hashets.WrapPrecomputedFS is similar to hashets.WrapFS except it uses
a precomputed hashets.Map for the provided filesystem, which ideally is
generated using go generate for the provided filesystem during compile time.
This saves the overhead of calculating the file hashes during startup.
Add a static.go to your static directory:
static
├── assets
│ └── file_to_hash.ext
└── static.go
package static
import (
"embed"
"github.com/mavolin/hashets/hashets"
)
//go:embed assets/*
var assets embed.FS
//go:generate hashets -map-only -o . assets
var (
// FileNames is defined in hashets_map.go, which is generated
// by `hashets` during code generation
FS = hashets.WrapPrecomputedFS(assets, FileNames)
)FS can now be used exactly the same as in the previous example. Using this
method does not offer the same guarantees regarding integrity as computing
the hashes at runtime, as there is no integrity check for the hashes and
their associated files. This race condition is generally known as
time-of-check/time-of-use. The following shell replay demonstrates
the problem:
go generate ./... # this will compute the FileNames map variable
date > static/assets/file_to_hash.ext
date > static/assets/new_file.ext
go install .The filesystem assets embedded into the binary will now have one additional
file the FileNames map does not know about in addition to the existing file
(file_to_hash.ext) having changed after computing its hash.
Using this approach is only recommended if you have tight control over the build pipeline or simply do not care about the cache busting quality of the application. The example below somewhat mitigates that risk, providing a clearer distinction between already processed files and their originals, at the cost of file duplication.
This method is for you, if:
- 📏 You have larger assets and need lightning fast startup times
- 🕑 You know your assets at compile time
- 🕵 You need cache busting during development and not just in production
Add a static.go to your static directory:
static
├── orig
│ └── file_to_hash.ext
└── static.go
package static
import "static/hashed"
//go:generate hashets -o hashed origNow run go generate.
Your file structure should now look like this:
static
├── hashed
│ ├── file_to_hash_generatedHash.ext
│ └── hashets_map.go
├── orig
│ └── file_to_hash.ext
└── static.go
Besides the hashed files, hashets also generated a hashets_map.go file,
that contains the FileNames hashets.Map, that maps the original file names
to their hashed equivalents:
package hashed
import "github.com/mavolin/hashets/hashets"
var FileNames = hashets.Map{
"file_to_hash.ext": "file_to_hash_generatedHash.ext",
}This method is for you, if:
- 📏 You have larger assets and need lightning fast startup times
- 🕑 You know your assets at compile time
- 🤷 You don't need cache busting during development
The go generate solution has one big drawback:
If you generate static assets in the same go generate run and hashets is
executed before the files are generated, the hashes will be wrong.
Luckily, there is another handy solution:
Add a static.go and a hashets_map.go to your static directory:
static
├── file_to_hash.ext
├── hashets_map.go
└── static.go
static.go
package static
import "embed"
// It is important that you use wildcards for your files, as otherwise the
// hashed files generated by your CI won't be included in the embed.FS.
//go:embed file_to_hash*.txt
var FS embed.FShashets_map.go
package static
import "github.com/mavolin/hashets"
// FileNames maps the original file names to their hashed equivalents.
// Unless you run hashets, this map will be nil, which causes [hashets.Map.Get]
// to behave specially:
// Instead of returning the hashed file name, it will return the path that it
// is given as-is.
//
// That means, unless you run hashets, you will simply use your unhashed assets.
var FileNames hashets.MapNow, in your CI, run hashets before compiling:
hashets -replace -ignore static.go staticThis will replace all of your assets with their hashed equivalents, i.e.
replace file_to_hash.ext with file_to_hash_generatedHash.ext.
Additionally, it will overwrite hashets_map.go with a FileNames map that
contains the correct mappings.
This method is for you, if:
- 🛠 You need maximum customizability
- 🤏 You have smaller assets or don't mind if your application takes a few milliseconds longer to start
- 🕚 You know your assets at compile or runtime
- 🕵 You need cache busting during development and not just in production
If all of the above don't do the trick for you, you can also create hashes
using hashets.HashToDir and hashets.HashToTempDir, which will generate
hashed files and write them to an arbitrary or a temporary directory.
Head over to pkg.go.dev to read more.
Built with ❤ by Maximilian von Lindern. Available under the MIT License.