From 17164f42d0081640c695f4ef5751b8a357e61a75 Mon Sep 17 00:00:00 2001 From: Cell <1024@lruihao.cn> Date: Sun, 31 May 2026 17:54:02 +0800 Subject: [PATCH] fix(assets): rewrite service worker with two-strategy caching (#774) - Replace legacy service-worker.js (imported from DoIt) with a clean rewrite using Hugo's ExecuteAsTemplate for fingerprinted URL injection - Cache-first for static assets (immutable/fingerprinted), network-first for HTML pages with LRU eviction (max 100 entries) - Fix LRU eviction to protect precached URLs using absolute URL comparison - Add Fingerprint param to js-build.html partial to reduce boilerplate - Extract to-css.html partial for reusable SCSS-to-CSS pipeline - Support pre-built Resource param in script.html and style.html - Store mainCSS and mainJS in Site.Store to avoid duplicate builds - Remove explicit scope from serviceWorker.register() (defaults to /) Closes #298 --- .vscode/settings.json | 2 + assets/js/modules/misc.ts | 2 +- assets/js/service-worker.js | 267 ----------------------- assets/js/service-worker.template.js | 165 ++++++++++++++ eslint.config.js | 3 +- layouts/_partials/base/assets.html | 23 +- layouts/_partials/base/head/index.html | 17 +- layouts/_partials/function/js-build.html | 4 + layouts/_partials/function/to-css.html | 35 +++ layouts/_partials/plugin/script.html | 29 ++- layouts/_partials/plugin/style.html | 63 +++--- 11 files changed, 278 insertions(+), 332 deletions(-) delete mode 100644 assets/js/service-worker.js create mode 100644 assets/js/service-worker.template.js create mode 100644 layouts/_partials/function/to-css.html diff --git a/.vscode/settings.json b/.vscode/settings.json index b039f432..ad679cf9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,8 @@ "vercel.json": "build.sh" }, "files.associations": { + "*.template.ts": "gots", + "*.template.js": "gojs", "*.template.scss": "gocss", "*.template.svg": "html", "**/plugin/script.html": "gohtml", diff --git a/assets/js/modules/misc.ts b/assets/js/modules/misc.ts index 4ab4866b..98617b27 100644 --- a/assets/js/modules/misc.ts +++ b/assets/js/modules/misc.ts @@ -53,7 +53,7 @@ export class MiscModule implements MiscService { initServiceWorker() { if (this.core.config.PWA?.enable && 'serviceWorker' in navigator) { navigator.serviceWorker - .register(this.core.config.PWA.serviceWorkerURL, { scope: '/' }) + .register(this.core.config.PWA.serviceWorkerURL) .then((_registration) => { // console.log('Service Worker Registered'); }) diff --git a/assets/js/service-worker.js b/assets/js/service-worker.js deleted file mode 100644 index d3d41688..00000000 --- a/assets/js/service-worker.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Service Worker - * imported from https://github.com/HEIGE-PCloud/DoIt/blob/v0.2.11/src/js/sw.js - * [todo] rewrite with TypeScript and fixes issue #298 - */ -const CACHE_VERSION = 1; - -const BASE_CACHE_FILES = [ - '/css/style.min.css', - '/js/theme.min.js', - '/site.webmanifest', -]; - -const OFFLINE_CACHE_FILES = [ - '/css/style.min.css', - '/js/theme.min.js', - '/site.webmanifest', - '/offline/' -]; - -const NOT_FOUND_CACHE_FILES = [ - '/css/style.min.css', - '/js/theme.min.js', - '/site.webmanifest', - '/404.html' -]; - -const OFFLINE_PAGE = '/offline/'; -const NOT_FOUND_PAGE = '/404.html'; - -const CACHE_VERSIONS = { - assets: 'assets-v' + CACHE_VERSION, - content: 'content-v' + CACHE_VERSION, - offline: 'offline-v' + CACHE_VERSION, - notFound: '404-v' + CACHE_VERSION -}; - -// Define MAX_TTL's in SECONDS for specific file extensions -const MAX_TTL = { - '/': 3600, - html: 3600, - json: 86400, - js: 86400, - css: 86400 -}; - -const CACHE_BLACKLIST = [ - (str) => { - return !str.startsWith('http://localhost'); - } -]; - -const SUPPORTED_METHODS = ['GET']; - -/** - * isBlackListed - * @param {string} url - * @returns {boolean} - */ -function isBlacklisted(url) { - return CACHE_BLACKLIST.length > 0 - ? !CACHE_BLACKLIST.filter((rule) => { - if (typeof rule === 'function') { - return !rule(url); - } else { - return false; - } - }).length - : false; -} - -/** - * getFileExtension - * @param {string} url - * @returns {string} - */ -function getFileExtension(url) { - const extension = url.split('.').reverse()[0].split('?')[0]; - return extension.endsWith('/') ? '/' : extension; -} - -/** - * getTTL - * @param {string} url - */ -function getTTL(url) { - if (typeof url === 'string') { - const extension = getFileExtension(url); - if (typeof MAX_TTL[extension] === 'number') { - return MAX_TTL[extension]; - } else { - return null; - } - } else { - return null; - } -} - -/** - * installServiceWorker - * @returns {Promise} - */ -function installServiceWorker() { - return Promise.all([ - caches.open(CACHE_VERSIONS.assets).then((cache) => { - return cache.addAll(BASE_CACHE_FILES); - }), - caches.open(CACHE_VERSIONS.offline).then((cache) => { - return cache.addAll(OFFLINE_CACHE_FILES); - }), - caches.open(CACHE_VERSIONS.notFound).then((cache) => { - return cache.addAll(NOT_FOUND_CACHE_FILES); - }) - ]).then(() => { - return self.skipWaiting(); - }); -} - -/** - * cleanupLegacyCache - * @returns {Promise} - */ -function cleanupLegacyCache() { - const currentCaches = Object.keys(CACHE_VERSIONS).map((key) => { - return CACHE_VERSIONS[key]; - }); - - return new Promise((resolve, reject) => { - caches - .keys() - .then((keys) => { - return keys.filter((key) => { - return !~currentCaches.indexOf(key); - }); - }) - .then((legacy) => { - if (legacy.length) { - Promise.all( - legacy.map((legacyKey) => { - return caches.delete(legacyKey); - }) - ) - .then(() => { - resolve(); - }) - .catch((err) => { - reject(err); - }); - } else { - resolve(); - } - }) - .catch((err) => { - reject(err); - }); - }); -} - -self.addEventListener('install', (event) => { - event.waitUntil(Promise.all([installServiceWorker(), self.skipWaiting()])); -}); - -// The activate handler takes care of cleaning up old caches. -self.addEventListener('activate', (event) => { - event.waitUntil( - Promise.all([ - cleanupLegacyCache(), - self.clients.claim(), - self.skipWaiting() - ]).catch((err) => { - console.warn(err); - self.skipWaiting(); - }) - ); -}); - -self.addEventListener('fetch', (event) => { - event.respondWith( - caches.open(CACHE_VERSIONS.content).then((cache) => { - return cache - .match(event.request) - .then((response) => { - if (response) { - const headers = response.headers.entries(); - let date = null; - - for (const pair of headers) { - if (pair[0] === 'date') { - date = new Date(pair[1]); - } - } - if (date) { - const age = parseInt( - (new Date().getTime() - date.getTime()) / 1000 - ); - const ttl = getTTL(event.request.url); - - if (ttl && age > ttl) { - return new Promise((resolve) => { - return fetch(event.request.clone()) - .then((updatedResponse) => { - if (updatedResponse) { - cache.put(event.request, updatedResponse.clone()); - resolve(updatedResponse); - } else { - resolve(response); - } - }) - .catch(() => { - resolve(response); - }); - }).catch((err) => { - console.warn(err); - return response; - }); - } else { - return response; - } - } else { - return response; - } - } else { - return null; - } - }) - .then((response) => { - if (response) { - return response; - } else { - return fetch(event.request.clone()) - .then((response) => { - if (response.status < 400) { - if ( - ~SUPPORTED_METHODS.indexOf(event.request.method) && - !isBlacklisted(event.request.url) && - event.request.url.slice(0, 4) === 'http' - ) { - cache.put(event.request, response.clone()); - } - return response; - } else { - return caches.open(CACHE_VERSIONS.notFound).then((cache) => { - return cache.match(NOT_FOUND_PAGE); - }); - } - }) - .then((response) => { - if (response) { - return response; - } - }) - .catch(() => { - return caches - .open(CACHE_VERSIONS.offline) - .then((offlineCache) => { - return offlineCache.match(OFFLINE_PAGE); - }); - }); - } - }) - .catch((error) => { - console.error(' Error in fetch handler:', error); - throw error; - }); - }) - ); -}); diff --git a/assets/js/service-worker.template.js b/assets/js/service-worker.template.js new file mode 100644 index 00000000..e1474fda --- /dev/null +++ b/assets/js/service-worker.template.js @@ -0,0 +1,165 @@ +/* eslint-disable no-restricted-globals -- `self` is the standard Service Worker global */ + +/** + * Service Worker + * @description Two-strategy caching with the native Cache API: + * - Static assets (fingerprinted CSS/JS): cache-first, immutable + * - HTML pages: network-first, fallback to cache when offline + * Asset URLs are injected at build time via Hugo's ExecuteAsTemplate, + * ensuring they match fingerprinted paths when fingerprint is enabled. + * @see https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook + */ + +/* ========== Constants ========== */ + +const CACHE_NAME = 'fixit-v1' + +/** URLs to pre-cache during the install event (injected by Hugo). */ +const PRECACHE_URLS = [ + '{{ .mainCSSURL }}', + '{{ .mainJSURL }}', + '{{ .relURL }}site.webmanifest', + '{{ .offlineURL }}', + '{{ .relURL }}404.html', +] + +const OFFLINE_PAGE = '{{ .offlineURL }}' +const NOT_FOUND_PAGE = '{{ .relURL }}404.html' + +/** Maximum number of HTML pages to keep in cache (LRU eviction). */ +const MAX_HTML_CACHE_ENTRIES = 100 + +/** Static asset extensions — these are fingerprinted by Hugo and safe to cache forever. */ +const STATIC_EXTENSIONS = ['css', 'js', 'woff2', 'woff', 'ttf', 'eot', 'svg', 'webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'ico'] + +/* ========== Helpers ========== */ + +/** Cached cache instance — avoids repeated caches.open() calls. */ +const cachePromise = caches.open(CACHE_NAME) + +/** + * Check if a URL is a same-origin static asset (fingerprinted by Hugo). + * Returns false for HTML pages and extensionless paths. + */ +function isStaticAsset(url) { + if (!url.startsWith(self.location.origin)) + return false + const pathname = new URL(url).pathname + const dotIndex = pathname.lastIndexOf('.') + if (dotIndex === -1) + return false + const ext = pathname.slice(dotIndex + 1).toLowerCase() + return STATIC_EXTENSIONS.includes(ext) +} + +/** + * Evict oldest HTML cache entries when the limit is exceeded. + * Operates on cache insertion order (Cache API preserves insertion order for keys()). + * Skips pre-cached URLs (offline/404 pages) to ensure they are never evicted. + */ +async function evictOldEntries() { + const cache = await cachePromise + const keys = await cache.keys() + // PRECACHE_URLS contains relative paths from Hugo; resolve them to absolute URLs for comparison. + const precacheSet = new Set(PRECACHE_URLS.map(url => new URL(url, self.location.origin).href)) + const htmlKeys = keys.filter(req => !isStaticAsset(req.url) && !precacheSet.has(req.url)) + const excess = htmlKeys.length - MAX_HTML_CACHE_ENTRIES + if (excess > 0) { + await Promise.all(htmlKeys.slice(0, excess).map(req => cache.delete(req))) + } +} + +/* ========== Lifecycle ========== */ + +/** Pre-cache core assets on install (individual calls for resilience). */ +self.addEventListener('install', (event) => { + event.waitUntil( + cachePromise + .then(cache => Promise.allSettled( + PRECACHE_URLS.filter(Boolean).map(url => cache.add(url)), + )) + .then(() => self.skipWaiting()), + ) +}) + +/** Clean up old caches, evict stale HTML entries, and take control immediately. */ +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)), + )) + .then(() => evictOldEntries()) + .then(() => self.clients.claim()), + ) +}) + +/* ========== Fetch Strategies ========== */ + +/** Route same-origin GET requests to the appropriate caching strategy. */ +self.addEventListener('fetch', (event) => { + const { request } = event + + // Cache API only supports GET + if (request.method !== 'GET' || !request.url.startsWith(self.location.origin)) + return + + event.respondWith( + isStaticAsset(request.url) + ? cacheFirst(request) + : networkFirst(request), + ) +}) + +/** + * Cache-first strategy for fingerprinted static assets. + * These are immutable — a content change produces a new URL, + * so cached responses are always valid. + */ +async function cacheFirst(request) { + const cache = await cachePromise + const cached = await cache.match(request) + if (cached) + return cached + + try { + const response = await fetch(request) + if (response.ok) { + cache.put(request, response.clone()) + } + return response + } + catch { + return new Response('Offline', { status: 503 }) + } +} + +/** + * Network-first strategy for HTML pages. + * Always tries the network for fresh content, falls back to cache on failure. + */ +async function networkFirst(request) { + try { + const response = await fetch(request) + if (response.ok) { + const cache = await cachePromise + cache.put(request, response.clone()) + // Evict oldest HTML entries to keep cache bounded + evictOldEntries() + } + // Server error (4xx/5xx) — try 404 page from cache + if (response.status >= 400) { + return (await caches.match(NOT_FOUND_PAGE)) || response + } + return response + } + catch { + // Network failure — try cached page, then offline fallback + try { + return (await caches.match(request)) || (await caches.match(OFFLINE_PAGE)) || new Response('Offline', { status: 503 }) + } + catch { + return new Response('Offline', { status: 503 }) + } + } +} diff --git a/eslint.config.js b/eslint.config.js index 8c276afc..1ea744bb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,6 @@ export default antfu({ 'layouts/**/*.json', 'layouts/**/*.xml', 'layouts/**/*.md', - // ignore temporarily - 'assets/js/service-worker.js', + '**/*.template.*', ], }) diff --git a/layouts/_partials/base/assets.html b/layouts/_partials/base/assets.html index 28429040..58433099 100644 --- a/layouts/_partials/base/assets.html +++ b/layouts/_partials/base/assets.html @@ -290,12 +290,16 @@ {{- /* PWA */ -}} {{- if not hugo.IsServer | and .Site.Params.enablePWA | and hugo.IsProduction -}} - {{- $serviceWorker := dict "Resource" (resources.Get "js/service-worker.js") "Build" true | partial "function/js-build.html" -}} - {{- with $fingerprint -}} - {{- $serviceWorker = $serviceWorker | fingerprint . -}} + {{- $offlineURL := "" -}} + {{- with .Site.Home.OutputFormats.Get "offline" -}} + {{- $offlineURL = .RelPermalink -}} {{- end -}} - {{- $serviceWorkerURL := $serviceWorker.RelPermalink -}} - {{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorkerURL) | merge $config -}} + {{- $mainCSS := .Site.Store.Get "mainCSS" -}} + {{- $mainJS := .Site.Store.Get "mainJS" -}} + {{- $swBuildCtx := dict "mainCSSURL" $mainCSS.RelPermalink "mainJSURL" $mainJS.RelPermalink "offlineURL" $offlineURL "relURL" (relURL "") "Page" . -}} + {{- $serviceWorker := resources.Get "js/service-worker.template.js" | resources.ExecuteAsTemplate "js/service-worker.template.js" $swBuildCtx -}} + {{- $serviceWorker = dict "Resource" $serviceWorker "Build" (dict "targetPath" "sw.js") "Fingerprint" $fingerprint | partial "function/js-build.html" -}} + {{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorker.RelPermalink) | merge $config -}} {{- end -}} {{- /* Auto Bookmark */ -}} @@ -366,10 +370,7 @@ {{- $layoutLoaders := $mermaid.layoutloaders | default slice -}} {{- $options := dict "format" "esm" -}} - {{- $mermaidModule := dict "Resource" (resources.Get "js/lib/mermaid.ts") "Build" $options | partial "function/js-build.html" -}} - {{- with $fingerprint -}} - {{- $mermaidModule = $mermaidModule | fingerprint . -}} - {{- end -}} + {{- $mermaidModule := dict "Resource" (resources.Get "js/lib/mermaid.ts") "Build" $options "Fingerprint" $fingerprint | partial "function/js-build.html" -}} {{- $mermaidModuleURL := $mermaidModule.RelPermalink -}} {{- $bootstrapJS := dict @@ -384,8 +385,8 @@ {{- dict "Content" $bootstrapJS "Path" "js/lib/mermaid-bootstrap.js" "Minify" hugo.IsProduction "Attr" `type="module"` "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} -{{- /* Theme main bundle */ -}} -{{- dict "Source" "js/main.ts" "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} +{{- /* Theme main bundle (built once in head/index.html) */ -}} +{{- dict "Resource" (.Site.Store.Get "mainJS") "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- /* Custom assets block */ -}} {{- block "custom-assets" . }}{{ end -}} diff --git a/layouts/_partials/base/head/index.html b/layouts/_partials/base/head/index.html index 3d92a7ab..68ec12ed 100644 --- a/layouts/_partials/base/head/index.html +++ b/layouts/_partials/base/head/index.html @@ -75,8 +75,7 @@ {{- $title = .Site.Title -}} {{- end -}} -{{- /* Favicon links */ -}} -{{/* [todo] 先判断有图片资源时才加载,没有时提示引导 https://realfavicongenerator.net/ */}} +{{- /* Favicon links (static resources, generated via https://realfavicongenerator.net/) */ -}} {{- if not .Site.Params.app.noFavicon -}} {{- $relURL := relURL "" -}} {{- with .Site.Params.app.svgFavicon -}} @@ -125,12 +124,14 @@ -}} {{- /* Theme main CSS */ -}} -{{- dict - "Source" "scss/main.scss" +{{- $mainCSS := dict + "Resource" (resources.Get "scss/main.scss") "ToCSS" true "Fingerprint" $fingerprint - | partial "plugin/style.html" + | partial "function/to-css.html" -}} +{{- .Site.Store.Set "mainCSS" $mainCSS -}} +{{- dict "Resource" $mainCSS | partial "plugin/style.html" -}} {{- /* Font Awesome Icons */ -}} {{- $source := $cdn.fontawesomeFreeCSS | default "lib/fontawesome-free/all.min.css" -}} @@ -143,10 +144,8 @@ {{- partial "plugin/style.html" $options -}} {{- /* Preload main theme bundle */ -}} -{{- $mainResource := dict "Resource" (resources.Get "js/main.ts") "Build" true | partial "function/js-build.html" -}} -{{- with $fingerprint -}} - {{- $mainResource = $mainResource | fingerprint . -}} -{{- end -}} +{{- $mainResource := dict "Resource" (resources.Get "js/main.ts") "Build" true "Fingerprint" $fingerprint | partial "function/js-build.html" -}} +{{- .Site.Store.Set "mainJS" $mainResource -}} {{- /* ========== SEO: Structured Data (Schema.org) ========== */ -}} diff --git a/layouts/_partials/function/js-build.html b/layouts/_partials/function/js-build.html index 426ec2bd..7fb1e532 100644 --- a/layouts/_partials/function/js-build.html +++ b/layouts/_partials/function/js-build.html @@ -3,6 +3,7 @@ @param {resource.Resource} .Resource Hugo resource to process @param {Object|Boolean} [.Build] true for defaults, dict for custom options, false/omit to skip js.Build @param {Boolean} [.Minify] extra minify flag; final minify is Minify OR Build.minify + @param {String} [.Fingerprint] fingerprint algorithm (e.g. "sha256") @return {resource.Resource} */ -}} @@ -25,4 +26,7 @@ {{- if $minify -}} {{- $resource = $resource | minify -}} {{- end -}} +{{- with .Fingerprint -}} + {{- $resource = $resource | fingerprint . -}} +{{- end -}} {{- return $resource -}} diff --git a/layouts/_partials/function/to-css.html b/layouts/_partials/function/to-css.html new file mode 100644 index 00000000..b30a9bdf --- /dev/null +++ b/layouts/_partials/function/to-css.html @@ -0,0 +1,35 @@ +{{- /* + Compile SCSS to CSS via dartsass, optionally minify and fingerprint. + @param {resource.Resource} .Resource SCSS resource + @param {Object|Boolean} [.ToCSS] true for defaults, dict for custom toCSS options, false/omit to skip + @param {Boolean} [.Minify] whether to minify; defaults to hugo.IsProduction when ToCSS is used + @param {String} [.Fingerprint] fingerprint algorithm (e.g. "sha256") + @return {resource.Resource} +*/ -}} + +{{- $resource := .Resource -}} +{{- $minify := .Minify -}} +{{- with .ToCSS -}} + {{- $targetPath := replaceRE `\.(template\.)?scss$` ".css" $resource.Name -}} + {{- $targetPath = replace $targetPath "scss" "css" -}} + {{- $defaultOptions := dict + "transpiler" "dartsass" + "enableSourceMap" hugo.IsDevelopment + "outputStyle" (cond hugo.IsDevelopment "expanded" "compressed") + "targetPath" $targetPath + -}} + {{- $options := . -}} + {{- if eq $options true -}} + {{- $options = dict -}} + {{- end -}} + {{- $options = $options | merge $defaultOptions -}} + {{- $resource = $resource | toCSS $options -}} + {{- $minify = $minify | default hugo.IsProduction -}} +{{- end -}} +{{- if $minify -}} + {{- $resource = $resource | minify -}} +{{- end -}} +{{- with .Fingerprint -}} + {{- $resource = $resource | fingerprint . -}} +{{- end -}} +{{- return $resource -}} diff --git a/layouts/_partials/plugin/script.html b/layouts/_partials/plugin/script.html index f3c141df..4599b3f9 100644 --- a/layouts/_partials/plugin/script.html +++ b/layouts/_partials/plugin/script.html @@ -1,8 +1,9 @@ {{- /* Script tag renderer. - - Accepts direct script HTML or builds script source from local/remote resources. + - Accepts direct script HTML, a pre-built resource, or builds from source path. - Supports template execution, minify/build/fingerprint pipeline, and extra attrs. - @param {String|Resource} .Source script source URL/path or Hugo resource; if string starts with " HTML or builds CSS resources from local paths/content. + - Accepts direct HTML, a pre-built resource, or builds from source path. - Supports template execution, optional toCSS, minify/fingerprint, and preload mode. - @param {String} .Source stylesheet URL/path or Hugo resource; if string starts with "