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:
co-authored by
Claude Opus 5
parent
6f108b6584
commit
59bcc18495
@@ -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
@@ -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
@@ -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 ||
|
||||
|
||||
Vendored
+5
@@ -6,3 +6,8 @@ declare module "*.vue" {
|
||||
}
|
||||
|
||||
declare const __CLOUD_URL__: string;
|
||||
|
||||
declare module "*.yml" {
|
||||
const messages: Record<string, any>;
|
||||
export default messages;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
-8
@@ -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:
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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`);
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user