Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package main

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
Expand Down Expand Up @@ -125,6 +128,69 @@ func buildContentSecurityPolicy() string {
"frame-ancestors 'none'"
}

// assetVersions maps a served static path to a short hash of its contents,
// computed once at startup. The stylesheet and the page scripts live at fixed
// paths, so without a version in the URL a CDN or a browser can pair a cached
// copy of one deploy with the HTML of the next: every class name the new markup
// asks for resolves to nothing and the page renders unstyled. Versioning the URL
// makes a changed asset a different URL, which no cache can confuse for the old
// one.
var assetVersions = map[string]string{}

// versionedAssets are the first-party files referenced from the templates whose
// contents change between deploys. Images are excluded: they are replaced rarely
// and never in a way that breaks a page that fetched the previous copy.
var versionedAssets = []string{
"/app.css",
"/index.js",
"/render-body.js",
"/har.js",
"/endpoint.js",
}

func hashAsset(path string) string {
f, err := os.Open("./public" + path)
if err != nil {
slog.Warn("asset missing, serving unversioned", "path", path, "err", err)
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
slog.Warn("asset unreadable, serving unversioned", "path", path, "err", err)
return ""
}
return hex.EncodeToString(h.Sum(nil))[:10]
}

// assetURL is exposed to templates as `asset`. Production reads the map computed
// at startup. Development re-hashes, but only when the file's modification time
// has moved, so an edited stylesheet is picked up without a restart and without
// re-reading every asset on every render.
var assetMu sync.Mutex
var assetStamps = map[string]time.Time{}

func assetURL(path string) string {
if isProduction {
if version := assetVersions[path]; version != "" {
return path + "?v=" + version
}
return path
}

assetMu.Lock()
defer assetMu.Unlock()
info, err := os.Stat("./public" + path)
if err == nil && !info.ModTime().Equal(assetStamps[path]) {
assetStamps[path] = info.ModTime()
assetVersions[path] = hashAsset(path)
}
if version := assetVersions[path]; version != "" {
return path + "?v=" + version
}
return path
}

// trustedProxyConfig lists the peers whose X-Forwarded-* headers Fiber may
// honour once TrustProxy is enabled. httphq is only ever fronted by a reverse
// proxy that reaches it from a private, loopback or link-local address; no
Expand Down Expand Up @@ -321,6 +387,11 @@ func main() {

engine := html.New("./src/views", ".html")

for _, path := range versionedAssets {
assetVersions[path] = hashAsset(path)
}
engine.AddFunc("asset", assetURL)

if !isProduction {
engine.Reload(true)
engine.Debug(true)
Expand Down
10 changes: 5 additions & 5 deletions src/views/layouts/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<!-- Built from src/styles/app.css by `npm run css`. The only stylesheet on
the marketing and contact pages, and the only render-blocking resource
anywhere: no page waits on a third party to have a layout. -->
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="{{asset `/app.css`}}" />

{{if .AppScripts}}
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
Expand All @@ -61,10 +61,10 @@
<!-- App scripts must register their alpine:init listeners BEFORE Alpine
loads, because Alpine v3 schedules its first alpine:init dispatch via
queueMicrotask at the end of its own script execution. -->
<script defer src="/index.js"></script>
<script defer src="/render-body.js"></script>
<script defer src="/har.js"></script>
<script defer src="/endpoint.js"></script>
<script defer src="{{asset `/index.js`}}"></script>
<script defer src="{{asset `/render-body.js`}}"></script>
<script defer src="{{asset `/har.js`}}"></script>
<script defer src="{{asset `/endpoint.js`}}"></script>

<!-- Alpine.js (loaded last so app scripts can subscribe first). -->
<script
Expand Down
Loading