From af4412f667cacf89181ac1908754b061aeab0eea Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Fri, 10 Apr 2026 13:09:13 -0700 Subject: [PATCH] feat: add PWA support with dynamic base URL (#4608) Co-authored-by: Claude Opus 4.6 (1M context) --- assets/main.ts | 4 +++ internal/web/manifest.go | 29 ++++++++++++++++++++ internal/web/routes.go | 2 ++ internal/web/sw.go | 54 +++++++++++++++++++++++++++++++++++++ public/manifest.webmanifest | 10 ------- 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 internal/web/manifest.go create mode 100644 internal/web/sw.go delete mode 100644 public/manifest.webmanifest diff --git a/assets/main.ts b/assets/main.ts index 287e80d0..048bc74c 100644 --- a/assets/main.ts +++ b/assets/main.ts @@ -7,3 +7,7 @@ Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*. i.install?.(app), ); app.mount("#app"); + +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register(withBase("/sw.js")); +} diff --git a/internal/web/manifest.go b/internal/web/manifest.go new file mode 100644 index 00000000..bcd918a4 --- /dev/null +++ b/internal/web/manifest.go @@ -0,0 +1,29 @@ +package web + +import ( + "encoding/json" + "net/http" +) + +func (h *handler) manifest(w http.ResponseWriter, req *http.Request) { + base := "" + if h.config.Base != "/" { + base = h.config.Base + } + + manifest := map[string]any{ + "name": "Dozzle", + "short_name": "Dozzle", + "start_url": base + "/", + "display": "standalone", + "lang": "en", + "scope": base + "/", + "description": "A log viewer for containers", + "icons": []map[string]string{ + {"src": base + "/apple-touch-icon.png", "sizes": "512x512", "type": "image/png"}, + }, + } + + w.Header().Set("Content-Type", "application/manifest+json") + json.NewEncoder(w).Encode(manifest) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 51fb8004..c17db52b 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -199,6 +199,8 @@ func createRouter(h *handler) *chi.Mux { }) r.Get("/healthcheck", h.healthcheck) + r.Get("/manifest.webmanifest", h.manifest) + 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) { diff --git a/internal/web/sw.go b/internal/web/sw.go new file mode 100644 index 00000000..118ba76d --- /dev/null +++ b/internal/web/sw.go @@ -0,0 +1,54 @@ +package web + +import ( + "fmt" + "net/http" +) + +const serviceWorkerTemplate = ` +const CACHE_NAME = "dozzle-%s"; + +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all( + names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)) + ) + ) + ); + self.clients.claim(); +}); + +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}\./)) { + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }); + }) + ); + return; + } + + // Network-first for everything else (API calls, HTML, etc.) +}); +` + +func (h *handler) serviceWorker(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Cache-Control", "no-cache") + fmt.Fprintf(w, serviceWorkerTemplate, h.config.Version) +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest deleted file mode 100644 index d0538e73..00000000 --- a/public/manifest.webmanifest +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Dozzle", - "short_name": "Dozzle", - "start_url": "/", - "display": "standalone", - "lang": "en", - "scope": "/", - "description": "A log viewer for containers", - "icons": [{ "src": "/apple-touch-icon.png", "sizes": "512x512", "type": "image/png" }] -}