repos
/ orchard main

orchard

mirror

Every 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

2.4 KB · 86 lines · Go Raw History
 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}