From 59bcc184958494b923034554729b4ef04be2b16a Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Sat, 5 Sep 2026 17:03:36 -0700 Subject: [PATCH] perf: cut first-load cost and precompress assets at build time (#5018) Co-authored-by: Claude Opus 5 (1M context) --- Dockerfile | 1 + assets/main.css | 12 +++-- assets/modules/i18n.ts | 26 +++++----- assets/shims-vue.d.ts | 5 ++ internal/web/assets.go | 32 ++++++++++++ internal/web/assets_test.go | 101 ++++++++++++++++++++++++++++++++++++ internal/web/brotli.go | 37 ------------- internal/web/index.go | 93 +++++++++++++++++++++++++++------ internal/web/index_test.go | 8 ++- internal/web/routes.go | 2 +- internal/web/sw.go | 5 +- package.json | 4 +- pnpm-lock.yaml | 16 +++--- public/index.html | 3 ++ scripts/compress-dist.js | 61 ++++++++++++++++++++++ vite.config.ts | 2 + 16 files changed, 322 insertions(+), 86 deletions(-) create mode 100644 internal/web/assets.go create mode 100644 internal/web/assets_test.go delete mode 100644 internal/web/brotli.go create mode 100644 scripts/compress-dist.js diff --git a/Dockerfile b/Dockerfile index d8903d39..130c405b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./ COPY assets ./assets COPY locales ./locales COPY public ./public +COPY scripts ./scripts ARG CLOUD_URL ENV CLOUD_URL=$CLOUD_URL diff --git a/assets/main.css b/assets/main.css index b53501b8..a8a31adf 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,9 +1,9 @@ @import "tailwindcss"; @import "splitpanes/dist/splitpanes.css" layer(base); -@import "@fontsource/jetbrains-mono/400.css"; -@import "@fontsource/jetbrains-mono/500.css"; -@import "@fontsource/jetbrains-mono/600.css"; -@import "@fontsource/jetbrains-mono/700.css"; +/* The variable font covers weights 100-800 in one @font-face per subset. The four + * static weights needed 24 blocks (4 weights x 6 subsets), which was 28 KB of the + * gzipped critical CSS, and downloaded a separate file per weight actually used. */ +@import "@fontsource-variable/jetbrains-mono/wght.css"; @plugin "daisyui"; @plugin "@tailwindcss/typography"; @@ -14,7 +14,9 @@ --color-blue: oklch(65% 0.171 249.5); --color-orange: oklch(85% 0.186 48.13); - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --font-mono: + "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; } @utility pt-safe { diff --git a/assets/modules/i18n.ts b/assets/modules/i18n.ts index 10b9e3ab..a3456b76 100644 --- a/assets/modules/i18n.ts +++ b/assets/modules/i18n.ts @@ -2,15 +2,20 @@ import { type App } from "vue"; import { createI18n } from "vue-i18n"; import { locale } from "@/stores/settings"; import type { Locale } from "vue-i18n"; +import en from "../../locales/en.yml"; +// `en` is the fallback locale and is always needed before the app can mount, so it is +// imported statically and rides along in the entry chunk. Awaiting it as a dynamic +// import cost an extra round trip before the first render. Every other locale stays +// lazy, so `en` is excluded from the glob to avoid emitting a chunk nothing fetches. const localesMap = Object.fromEntries( - Object.entries(import.meta.glob("../../locales/*.yml")).map(([path, loadLocale]) => [ + Object.entries(import.meta.glob(["../../locales/*.yml", "!../../locales/en.yml"])).map(([path, loadLocale]) => [ path.match(/([\w-]*)\.yml$/)?.[1], loadLocale, ]), ) as Record Promise<{ default: Record }>>; -export const availableLocales = Object.keys(localesMap); +export const availableLocales = ["en", ...Object.keys(localesMap)].sort(); function setI18nLanguage(lang: Locale) { i18n.global.locale.value = lang; @@ -19,17 +24,16 @@ function setI18nLanguage(lang: Locale) { export const i18n = createI18n({ legacy: false, - locale: "", + locale: "en", fallbackLocale: "en", - messages: {}, + // Widened so `locale` stays a plain string; a bare literal narrows it to "en". + messages: { en } as Record, }); -const loadedLanguages: string[] = []; -async function loadLanguage(lang: string, setLang = true): Promise { - if (setLang) { - if (i18n.global.locale.value === lang) return setI18nLanguage(lang); - if (loadedLanguages.includes(lang)) return setI18nLanguage(lang); - } +const loadedLanguages: string[] = ["en"]; +async function loadLanguage(lang: string): Promise { + if (i18n.global.locale.value === lang) return setI18nLanguage(lang); + if (loadedLanguages.includes(lang)) return setI18nLanguage(lang); const messages = await localesMap[lang](); i18n.global.setLocaleMessage(lang, messages.default); @@ -37,8 +41,6 @@ async function loadLanguage(lang: string, setLang = true): Promise { return setI18nLanguage(lang); } -await loadLanguage("en", false); // load default language - const userLocale = computed( () => locale.value || diff --git a/assets/shims-vue.d.ts b/assets/shims-vue.d.ts index 58c17271..55499deb 100644 --- a/assets/shims-vue.d.ts +++ b/assets/shims-vue.d.ts @@ -6,3 +6,8 @@ declare module "*.vue" { } declare const __CLOUD_URL__: string; + +declare module "*.yml" { + const messages: Record; + export default messages; +} diff --git a/internal/web/assets.go b/internal/web/assets.go new file mode 100644 index 00000000..d6adc867 --- /dev/null +++ b/internal/web/assets.go @@ -0,0 +1,32 @@ +package web + +import ( + "net/http" + "path" + "strings" +) + +// Built assets are brotli-compressed once at build time by scripts/compress-dist.js, +// which replaces each original with a `.br` sibling. These are the types it stores +// precompressed; everything else is served as-is. +var compressedTypes = map[string]string{ + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json", + ".map": "application/json", +} + +func acceptsBrotli(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept-Encoding"), "br") +} + +// contentTypeFor resolves the type from the original name, since the file on disk is +// the `.br` sibling. The table is hardcoded rather than read via mime.TypeByExtension +// because the runtime image has no system mime database. +func contentTypeFor(name string) string { + if t, ok := compressedTypes[path.Ext(name)]; ok { + return t + } + return "application/octet-stream" +} diff --git a/internal/web/assets_test.go b/internal/web/assets_test.go new file mode 100644 index 00000000..265dd0d0 --- /dev/null +++ b/internal/web/assets_test.go @@ -0,0 +1,101 @@ +package web + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/andybalholm/brotli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const assetJS = "export const answer = 42;\n" + +// Text assets ship only as the `.br` sibling written by scripts/compress-dist.js, so +// the handler negotiates the encoding itself rather than leaning on http.FileServer. +func assetHandler(t *testing.T) *handler { + t.Helper() + + var buf bytes.Buffer + bw := brotli.NewWriterLevel(&buf, brotli.BestCompression) + _, err := bw.Write([]byte(assetJS)) + require.NoError(t, err) + require.NoError(t, bw.Close()) + + h := &handler{ + content: fstest.MapFS{ + "assets/app-abc12345.js.br": {Data: buf.Bytes()}, + "assets/font-abc12345.woff2": {Data: []byte("not compressible")}, + }, + config: &Config{}, + } + fileServer = http.FileServer(http.FS(h.content)) + return h +} + +// index() runs behind http.StripPrefix, so it sees paths without a leading slash. +func assetRequest(name string, acceptEncoding string) *http.Request { + req := httptest.NewRequest("GET", "/"+name, nil) + req.URL.Path = name + if acceptEncoding != "" { + req.Header.Set("Accept-Encoding", acceptEncoding) + } + return req +} + +func TestServeAsset(t *testing.T) { + h := assetHandler(t) + + t.Run("serves the precompressed bytes to a brotli client", func(t *testing.T) { + req := assetRequest("assets/app-abc12345.js", "gzip, deflate, br, zstd") + w := httptest.NewRecorder() + + require.True(t, h.serveAsset(w, req, req.URL.Path)) + res := w.Result() + + assert.Equal(t, "br", res.Header.Get("Content-Encoding")) + assert.Equal(t, "text/javascript; charset=utf-8", res.Header.Get("Content-Type")) + assert.Equal(t, "Accept-Encoding", res.Header.Get("Vary")) + assert.NotEmpty(t, res.Header.Get("Content-Length")) + + body, err := io.ReadAll(brotli.NewReader(res.Body)) + require.NoError(t, err) + assert.Equal(t, assetJS, string(body)) + }) + + t.Run("inflates for a client that does not accept brotli", func(t *testing.T) { + req := assetRequest("assets/app-abc12345.js", "") + w := httptest.NewRecorder() + + require.True(t, h.serveAsset(w, req, req.URL.Path)) + res := w.Result() + + assert.Empty(t, res.Header.Get("Content-Encoding")) + assert.Equal(t, "text/javascript; charset=utf-8", res.Header.Get("Content-Type")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Equal(t, assetJS, string(body)) + }) + + t.Run("serves already-compressed assets untouched", func(t *testing.T) { + req := assetRequest("assets/font-abc12345.woff2", "br") + w := httptest.NewRecorder() + + require.True(t, h.serveAsset(w, req, req.URL.Path)) + res := w.Result() + + assert.Empty(t, res.Header.Get("Content-Encoding")) + body, _ := io.ReadAll(res.Body) + assert.Equal(t, "not compressible", string(body)) + }) + + t.Run("falls through so unknown paths reach the SPA template", func(t *testing.T) { + req := assetRequest("container/abc", "br") + assert.False(t, h.serveAsset(httptest.NewRecorder(), req, req.URL.Path)) + }) +} diff --git a/internal/web/brotli.go b/internal/web/brotli.go deleted file mode 100644 index afc681d0..00000000 --- a/internal/web/brotli.go +++ /dev/null @@ -1,37 +0,0 @@ -package web - -import ( - "io" - "net/http" - "strings" - - "github.com/andybalholm/brotli" -) - -type brotliWriter struct { - io.Writer - http.ResponseWriter -} - -func (w brotliWriter) Write(b []byte) (int, error) { - return w.Writer.Write(b) -} - -func Brotli(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if client supports Brotli - if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") { - next.ServeHTTP(w, r) - return - } - - w.Header().Set("Content-Encoding", "br") - w.Header().Add("Vary", "Accept-Encoding") - - brWriter := brotli.NewWriter(w) - defer brWriter.Close() - - bw := brotliWriter{Writer: brWriter, ResponseWriter: w} - next.ServeHTTP(bw, r) - }) -} diff --git a/internal/web/index.go b/internal/web/index.go index c82c2927..c7d2e5f6 100644 --- a/internal/web/index.go +++ b/internal/web/index.go @@ -4,6 +4,7 @@ import ( "html/template" "io" "sort" + "strconv" "strings" "encoding/json" @@ -13,18 +14,50 @@ import ( "github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/profile" + "github.com/andybalholm/brotli" "github.com/rs/zerolog/log" ) func (h *handler) index(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - _, err := h.content.Open(path) - if err == nil && req.URL.Path != "" && req.URL.Path != "/" { - w.Header().Set("Cache-Control", "max-age=31536000, immutable") - fileServer.ServeHTTP(w, req) - } else { - h.executeTemplate(w, req) + if path := req.URL.Path; path != "" && path != "/" && h.serveAsset(w, req, path) { + return } + h.executeTemplate(w, req) +} + +// serveAsset serves a built asset, reporting whether it handled the request; anything +// unknown falls through to the SPA template. Text assets exist only as the `.br` +// sibling written by scripts/compress-dist.js, so they are served as-is to the usual +// client and inflated for the rare one that does not accept brotli. +func (h *handler) serveAsset(w http.ResponseWriter, req *http.Request, name string) bool { + if file, err := h.content.Open(name); err == nil { + file.Close() + w.Header().Set("Cache-Control", cacheControlFor(name)) + fileServer.ServeHTTP(w, req) + return true + } + + file, err := h.content.Open(name + ".br") + if err != nil { + return false + } + defer file.Close() + + w.Header().Set("Cache-Control", cacheControlFor(name)) + w.Header().Set("Content-Type", contentTypeFor(name)) + w.Header().Add("Vary", "Accept-Encoding") + + if acceptsBrotli(req) { + w.Header().Set("Content-Encoding", "br") + if stat, err := file.Stat(); err == nil { + w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) + } + io.Copy(w, file) + return true + } + + io.Copy(w, brotli.NewReader(file)) + return true } func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) { @@ -106,14 +139,15 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) { } manifest := h.readManifest() - entryJS, styles := entryAssets(manifest, "assets/main.ts") + entryJS, styles, preloads := entryAssets(manifest, entryModule) data := map[string]any{ - "Config": config, - "Dev": h.config.Dev, - "Entry": entryJS, - "Styles": styles, - "Base": base, + "Config": config, + "Dev": h.config.Dev, + "Entry": entryJS, + "Styles": styles, + "Preload": preloads, + "Base": base, } file, err := h.content.Open("index.html") if err != nil { @@ -145,22 +179,42 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) { } } -// entryAssets resolves the entry chunk's script and every stylesheet it depends on. +// cacheControlFor keeps the year-long immutable cache for hashed files under assets/, +// which can always be busted by a new name. The unhashed root files (favicon.png, +// apple-touch-icon.png) cannot, so pinning them would strand a stale icon for a year. +func cacheControlFor(name string) string { + if strings.HasPrefix(name, "assets/") { + return "max-age=31536000, immutable" + } + return "max-age=3600" +} + +const entryModule = "assets/main.ts" + +// entryAssets resolves the entry chunk's script, every stylesheet it depends on, and +// every other chunk in its static import graph. +// // Vite splits scoped component CSS into its own chunk, and a chunk statically imported by // the entry gets no of its own, so linking only the entry's `css` left those // stylesheets to whichever lazy page happened to import them. Deep linking to a page that // didn't (a container view) then rendered shared components unstyled until the user // navigated somewhere that pulled the chunk in. -func entryAssets(manifest map[string]any, entry string) (string, []string) { +// +// The same walk yields the modulepreload list. Vite emits those hints when it owns +// index.html, but the page is a Go template served from public/, so without them the +// browser only discovers a chunk after parsing the one that imports it, serializing the +// graph into several round trips. +func entryAssets(manifest map[string]any, entry string) (string, []string, []string) { chunk, ok := manifest[entry].(map[string]any) if !ok { - return "", nil + return "", nil, nil } file, _ := chunk["file"].(string) seen := make(map[string]bool) styles := make([]string, 0, 4) + preloads := make([]string, 0, 32) var collect func(key string) collect = func(key string) { if seen[key] { @@ -173,6 +227,11 @@ func entryAssets(manifest map[string]any, entry string) (string, []string) { return } + // The entry itself is already requested by the {{- end }} diff --git a/scripts/compress-dist.js b/scripts/compress-dist.js new file mode 100644 index 00000000..fe605f68 --- /dev/null +++ b/scripts/compress-dist.js @@ -0,0 +1,61 @@ +// Precompresses the built assets with brotli and drops the originals. +// +// The server used to brotli every static response on the fly, at the library's +// default quality, for every visitor. Compressing once at quality 11 is both +// smaller and free at request time. The uncompressed copies are removed rather +// than kept alongside, so the embedded FS (and the binary) shrinks instead of +// doubling; internal/web/index.go serves the `.br` sibling and inflates it for +// the rare client that does not send `Accept-Encoding: br`. +// +// index.html and .vite/manifest.json are left alone: Go reads and parses both. +import { constants, brotliCompressSync } from "node:zlib"; +import { readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs"; +import { join, extname } from "node:path"; + +const DIST = "dist"; +const COMPRESSIBLE = new Set([".js", ".css", ".svg", ".json", ".map"]); +const SKIP = new Set([join(DIST, "index.html"), join(DIST, ".vite", "manifest.json")]); + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(path); + else yield path; + } +} + +let before = 0; +let after = 0; +let count = 0; + +for (const file of walk(DIST)) { + const size = statSync(file).size; + before += size; + + if (SKIP.has(file) || !COMPRESSIBLE.has(extname(file))) { + after += size; + continue; + } + + const compressed = brotliCompressSync(readFileSync(file), { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: size, + }, + }); + + // Brotli can lose on tiny files. Keeping the original also keeps the server's + // fast path honest, so only swap when it actually pays. + if (compressed.length >= size) { + after += size; + continue; + } + + writeFileSync(`${file}.br`, compressed); + unlinkSync(file); + after += compressed.length; + count++; +} + +const mb = (n) => (n / 1024 / 1024).toFixed(2); +console.log(`brotli: ${count} files, dist ${mb(before)} MB -> ${mb(after)} MB`); diff --git a/vite.config.ts b/vite.config.ts index 0c770a08..4e8274cc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,8 @@ export default defineConfig(() => ({ base: "./", define: { __CLOUD_URL__: JSON.stringify(process.env.CLOUD_URL || "https://cloud.dozzle.dev"), + // No component uses the options API, so drop that half of the Vue runtime. + __VUE_OPTIONS_API__: false, }, resolve: { alias: {