perf: cut first-load cost and precompress assets at build time (#5018)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-09-06 00:03:36 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 6f108b6584
commit 59bcc18495
16 changed files with 322 additions and 86 deletions
+1
View File
@@ -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
+7 -5
View File
@@ -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 {
+14 -12
View File
@@ -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<Locale, () => Promise<{ default: Record<string, string> }>>;
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<Locale, typeof en>,
});
const loadedLanguages: string[] = [];
async function loadLanguage(lang: string, setLang = true): Promise<Locale> {
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<Locale> {
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<Locale> {
return setI18nLanguage(lang);
}
await loadLanguage("en", false); // load default language
const userLocale = computed(
() =>
locale.value ||
+5
View File
@@ -6,3 +6,8 @@ declare module "*.vue" {
}
declare const __CLOUD_URL__: string;
declare module "*.yml" {
const messages: Record<string, any>;
export default messages;
}
+32
View File
@@ -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"
}
+101
View File
@@ -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))
})
}
-37
View File
@@ -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)
})
}
+76 -17
View File
@@ -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 <link> 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 <script> tag.
if f, ok := chunk["file"].(string); ok && key != entry {
preloads = append(preloads, f)
}
// Imports first, matching the order Vite itself emits, so the entry's stylesheet
// (Tailwind's) keeps the last word in the cascade.
if imports, ok := chunk["imports"].([]any); ok {
@@ -195,7 +254,7 @@ func entryAssets(manifest map[string]any, entry string) (string, []string) {
}
collect(entry)
return file, styles
return file, styles, preloads
}
func (h *handler) readManifest() map[string]any {
+6 -2
View File
@@ -32,17 +32,21 @@ func TestEntryAssets(t *testing.T) {
}`), &manifest)
assert.NoError(t, err)
entry, styles := entryAssets(manifest, "assets/main.ts")
entry, styles, preloads := entryAssets(manifest, "assets/main.ts")
assert.Equal(t, "assets/main-abc.js", entry)
// Statically imported chunks first, entry's own stylesheet last. Lazy page CSS is left
// to the preload helper.
assert.Equal(t, []string{"assets/shared-ghi.css", "assets/ContainerIcon-def.css", "assets/main-abc.css"}, styles)
// Every statically imported chunk gets a modulepreload hint. The entry is excluded,
// since the <script> tag already requests it, as are lazy pages.
assert.Equal(t, []string{"assets/ContainerIcon-def.js", "assets/shared-ghi.js"}, preloads)
}
func TestEntryAssetsMissingEntry(t *testing.T) {
entry, styles := entryAssets(map[string]any{}, "assets/main.ts")
entry, styles, preloads := entryAssets(map[string]any{}, "assets/main.ts")
assert.Empty(t, entry)
assert.Empty(t, styles)
assert.Empty(t, preloads)
}
+1 -1
View File
@@ -258,7 +258,7 @@ func createRouter(h *handler) *chi.Mux {
r.Get("/sw.js", h.serviceWorker)
defaultHandler := http.StripPrefix(strings.Replace(base+"/", "//", "/", 1), http.HandlerFunc(h.index))
r.With(Brotli).Get("/*", func(w http.ResponseWriter, req *http.Request) {
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
defaultHandler.ServeHTTP(w, req)
})
})
+3 -2
View File
@@ -26,8 +26,9 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Cache immutable hashed assets (Vite adds hashes to filenames)
if (url.pathname.match(/\/assets\/.*\.[a-f0-9]{8}\./)) {
// Cache immutable hashed assets. Rolldown appends a base64url hash after a dash
// (main-DDlQ-1D9.js), not a dot-separated hex one, so match that shape.
if (url.pathname.match(/\/assets\/.+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/)) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
+2 -2
View File
@@ -19,7 +19,7 @@
"watch:frontend": "vite --open http://localhost:3100/",
"watch:backend": "LIVE_FS=true DEV=true air -- --level debug --addr localhost:3100",
"dev": "concurrently --kill-others \"npm:watch:*\"",
"build": "vite build",
"build": "vite build && node scripts/compress-dist.js",
"preview": "LIVE_FS=true air -- --addr localhost:3100",
"release": "bumpp",
"test": "TZ=UTC vitest",
@@ -37,7 +37,6 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.11",
"@duckdb/duckdb-wasm": "1.33.1-dev57.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@iconify-json/carbon": "^1.2.26",
"@iconify-json/cil": "^1.2.3",
"@iconify-json/ic": "^1.2.4",
@@ -82,6 +81,7 @@
},
"devDependencies": {
"@apache-arrow/esnext-esm": "^21.2.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@iconify-json/ion": "^1.2.7",
"@iconify-json/material-symbols-light": "^1.2.90",
"@iconify-json/ri": "^1.2.10",
+8 -8
View File
@@ -136,9 +136,6 @@ importers:
'@duckdb/duckdb-wasm':
specifier: 1.33.1-dev57.0
version: 1.33.1-dev57.0
'@fontsource/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@iconify-json/carbon':
specifier: ^1.2.26
version: 1.2.26
@@ -266,6 +263,9 @@ importers:
'@apache-arrow/esnext-esm':
specifier: ^21.2.0
version: 21.2.0
'@fontsource-variable/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@iconify-json/ion':
specifier: ^1.2.7
version: 1.2.7
@@ -922,12 +922,12 @@ packages:
'@noble/hashes':
optional: true
'@fontsource-variable/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==}
'@fontsource-variable/playfair-display@5.3.0':
resolution: {integrity: sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA==}
'@fontsource/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -4624,9 +4624,9 @@ snapshots:
'@exodus/bytes@1.15.1': {}
'@fontsource-variable/playfair-display@5.3.0': {}
'@fontsource-variable/jetbrains-mono@5.3.0': {}
'@fontsource/jetbrains-mono@5.3.0': {}
'@fontsource-variable/playfair-display@5.3.0': {}
'@humanfs/core@0.19.2':
dependencies:
+3
View File
@@ -17,6 +17,9 @@
{{- else }} {{- range .Styles }}
<link rel="stylesheet" href="{{ $.Base }}/{{ . }}" />
{{- end }}
{{- range .Preload }}
<link rel="modulepreload" href="{{ $.Base }}/{{ . }}" />
{{- end }}
<script type="module" src="{{ .Base }}/{{ .Entry }}"></script>
{{- end }}
<meta name="description" content="A log viewer for containers" />
+61
View File
@@ -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`);
+2
View File
@@ -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: {