orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "io/fs"
7 "net/http"
8 "path"
9 "strings"
10 "sync"
11)
12
13// Static assets are served under a URL carrying a hash of their contents.
14//
15// Without it a deploy ships a new stylesheet and script that browsers do not
16// fetch, because the embedded filesystem has zero modification times, so
17// http.FileServer sends no Last-Modified and no ETag and a browser is free to
18// reuse what it has. That is exactly what happened: the try buttons were live
19// and did nothing, because the page was running the previous app.js.
20//
21// The other sites solve this with Vite's content-hashed filenames. This one has
22// no build step, so the hash goes in a query parameter and the answer is marked
23// immutable, which is safe precisely because the URL changes when the bytes do.
24type Assets struct {
25 fsys fs.FS
26 mu sync.RWMutex
27 tags map[string]string
28}
29
30func NewAssets(fsys fs.FS) *Assets {
31 return &Assets{fsys: fsys, tags: map[string]string{}}
32}
33
34// URL returns the versioned path for an asset, for a template to write out.
35func (a *Assets) URL(name string) string {
36 clean := strings.TrimPrefix(name, "/")
37
38 a.mu.RLock()
39 tag, ok := a.tags[clean]
40 a.mu.RUnlock()
41
42 if !ok || Reloaded {
43 // In development the file is re-read every time, so an edit shows on
44 // reload rather than at the next restart.
45 tag = a.hash(clean)
46 a.mu.Lock()
47 a.tags[clean] = tag
48 a.mu.Unlock()
49 }
50 if tag == "" {
51 return "/" + clean
52 }
53 return "/" + clean + "?v=" + tag
54}
55
56func (a *Assets) hash(name string) string {
57 b, err := fs.ReadFile(a.fsys, name)
58 if err != nil {
59 return ""
60 }
61 sum := sha256.Sum256(b)
62 return hex.EncodeToString(sum[:])[:12]
63}
64
65// Handler serves the files. A request carrying a version is cached hard, since
66// its URL cannot outlive its contents. One without is not cached at all, which
67// covers anything linked by hand.
68func (a *Assets) Handler() http.Handler {
69 files := http.FileServer(http.FS(a.fsys))
70 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71 if r.URL.Query().Get("v") != "" && !Reloaded {
72 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
73 } else {
74 w.Header().Set("Cache-Control", "no-cache")
75 }
76 // Content types the standard table gets wrong or leaves off.
77 switch path.Ext(r.URL.Path) {
78 case ".js":
79 w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
80 case ".css":
81 w.Header().Set("Content-Type", "text/css; charset=utf-8")
82 }
83 files.ServeHTTP(w, r)
84 })
85}