Skip to content

#️⃣ Cache busting for Go by using file hashes. Hashing can be done both at run or compile time.

License

Notifications You must be signed in to change notification settings

mavolin/hashets

hashets

Go Reference Test Code Coverage Go Report Card License MIT


About

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.

Main Features

  • ⚡ Three options:
    1. Either generate files with hashed names before compiling,
    2. use hashets.HashToDir or hashets.HashToTempDir at runtime,
    3. or create a hashets.FSWrapper which 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)

Examples

First impressions matter, so here are some examples of how to use hashets.

Using hashets.WrapFS

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.

Using go generate with hashets.WrapPrecomputedFS

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.

Using go generate with a separate "hashed" directory

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 orig

Now 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",
}

During CI

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.FS

hashets_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.Map

Now, in your CI, run hashets before compiling:

hashets -replace -ignore static.go static

This 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.

Using hashets.HashToDir and hashets.HashToTempDir

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.

License

Built with ❤ by Maximilian von Lindern. Available under the MIT License.

About

#️⃣ Cache busting for Go by using file hashes. Hashing can be done both at run or compile time.

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages