a-h / templ

A language for writing HTML user interfaces in Go.
https://templ.guide/
MIT License
7.14k stars 236 forks source link

proposal: Cache-bust Static Assets #804

Open x80486 opened 1 week ago

x80486 commented 1 week ago

Static assets are usually cached for optimal performance. However, when they are updated, there is a risk the browser or CDN serves up the stale, cached version instead of the new one.

Cache-busting is a technique to avoid this happening.

templ could implement a feature to wrap the resource links in the markup, if/when specified, with a function that provides this behavior. For instance, instead of using href="/styles/global.css", it could be something like href=templ.ResourceLink("/styles/global.css").

A hash to the linked path in the markup is generated, so a stale cached version never gets served; e.g.: http://localhost:8000/favicon.svg?__templ_c=7dce7a2cff78e87ab6c0cc0bc7b75b67092b9725.

joerdav commented 1 week ago

Hey! Interesting proposal!

Do you have any kind of thoughts on what the ResourceLink function would look like?

And a follow up on that, do we think this would be widely used enough that we want to support it in the templ codebase (@a-h might have some thoughts on this), or maybe it could be a function that we document that people can copy into their own projects?

x80486 commented 1 week ago

I think the safest path is to read the resource represented by the provided link and compute its hash. In that case, the mechanism will only use a new value if the are indeed changes within the file.

I'm not clear if this could change between operating systems. If that's the case, another approach should be taken, otherwise people with different operating systems are going to generate new values just by generating the templates — since these are usually not generated in a continuous integration environment, but delivered along with the source code.

Regarding the scope of the feature, it depends if the owners think if the idea fits within the scope and aims of the project. It looks very much like something you could have —even more since it should be really simple—, but you can draw the line on the other side as well.


UPDATE

I'm using templ with Fiber in a small project. I wrapped one of the stylesheets in a function call: <link href={ resourceWithHash("/path/to/resource/from/static/mount/point/file.css") } rel="stylesheet" />, but looks like it's quite the challenge because at that point the mount path for the static assets is not present.

func resourceWithHash(filepath string) string {
    file, err := os.Open(filepath)
    defer file.Close()
    if err != nil {
        return filepath
    }
    hash := sha256.New()
    if _, err := io.Copy(hash, file); err != nil {
        return filepath
    }
    return filepath + "?__templ_c=" + hex.EncodeToString(hash.Sum(nil))
}
a-h commented 1 week ago

I think this sort of thing would be better implemented before compile time. For example, if you had a directory that you knew you were serving up, you could write a script to auto-generate a file that contained a map of the URL to the SHA256 hash.

urlToHash := map[string]string{
  "/styles/global.css": "7dce7a2cff78e87ab6c0cc0bc7b75b67092b9725",
}

Then, your resourceWithHash function would be:

func resourceWithHash(url string) string {
    hash, hasHash := urlToHash[url]
    if !hasHash {
        return url
    }
    // TODO: Parse the URL and adapt the querystring instead of concat.
    return url + "?__templ_c=" + hash
}

So long as you're not making changes to static assets without redeploying your app, you're good to go. But if you are, you could store the contents of the urlToHash map in a JSON file that you store alongside the static assets, and get your app to download and update the in-memory map every few minutes.

Clearly, you wouldn't want to calculate the hash of the file on every request, since it's a waste of CPU.

Setting proper cache headers is also worth doing, e.g. https://medium.com/pixelpoint/best-practices-for-cache-control-settings-for-your-website-ff262b38c5a2

joerdav commented 1 week ago

Another possibility would be to use some metadata of your app as the cache value. This would similarly only work if your app was deployed all as one. And may involve cache busting when there are no changes to an asset.

func resourceWithHash(url string) string {
    bi, ok := debug.ReadBuildInfo()
    if !ok {
        return url
    }
    // TODO: Parse the URL and adapt the querystring instead of concat.
    return url + "?__templ_c=" + bi.Main.Version
}