diff --git a/knip.jsonc b/knip.jsonc index 346469a4..e12f035c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -33,7 +33,10 @@ "stream-browserify", // The PWA service worker entry is injected by vite-plugin-pwa, which Knip does not trace here. "workbox-core", - "workbox-precaching" + "workbox-expiration", + "workbox-precaching", + "workbox-routing", + "workbox-strategies" ], "ignoreIssues": { // This import is intentionally satisfied transitively through bitsocial-react-hooks. diff --git a/package.json b/package.json index 76c64a04..e4575010 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,10 @@ "tcp-port-used": "1.0.2", "typescript": "6.0.2", "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0", "zustand": "4.4.3" }, "scripts": { diff --git a/src/sw.ts b/src/sw.ts index 9ea9386e..ffaba5af 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -2,26 +2,51 @@ /* eslint-disable no-restricted-globals */ import { clientsClaim } from 'workbox-core'; +import { ExpirationPlugin } from 'workbox-expiration'; import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; -import { NetworkFirst } from 'workbox-strategies'; +import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; declare const self: ServiceWorkerGlobalScope; const precacheEntries = self.__WB_MANIFEST.filter((entry) => (typeof entry === 'string' ? entry !== 'index.html' : entry.url !== 'index.html')); +const runtimeAssetDestinations = new Set(['font', 'image', 'manifest', 'script', 'style']); +const scopeRoot = self.registration.scope.endsWith('/') ? self.registration.scope : `${self.registration.scope}/`; +const scopePath = (path: string) => new URL(path, scopeRoot).pathname; +const apiPath = scopePath('api'); +const apiPathPrefix = scopePath('api/'); +const internalPathPrefix = scopePath('_('); +const runtimeAssetPathPrefixes = ['assets/', 'translations/'].map(scopePath); +const isApiPath = (pathname: string) => pathname === apiPath || pathname.startsWith(apiPathPrefix); // Precache revisioned assets, but let navigations fetch fresh HTML first. cleanupOutdatedCaches(); precacheAndRoute(precacheEntries); registerRoute( - ({ request, url }) => request.mode === 'navigate' && !url.pathname.startsWith('/api') && !/^\/_\(.*\)/.test(url.pathname), + ({ request, url }) => request.mode === 'navigate' && !isApiPath(url.pathname) && !url.pathname.startsWith(internalPathPrefix), new NetworkFirst({ cacheName: 'html-cache', networkTimeoutSeconds: 3, }), ); +registerRoute( + ({ request, url }) => + url.origin === self.location.origin && + (runtimeAssetDestinations.has(request.destination) || runtimeAssetPathPrefixes.some((prefix) => url.pathname.startsWith(prefix))), + new StaleWhileRevalidate({ + cacheName: 'runtime-static-assets', + plugins: [ + new ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 60 * 60 * 24 * 30, + purgeOnQuotaError: true, + }), + ], + }), +); + // Standard SW lifecycle methods self.skipWaiting(); clientsClaim(); diff --git a/vite.config.js b/vite.config.js index 26edfdfe..258e1a96 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,74 @@ import { VitePWA } from 'vite-plugin-pwa'; const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion; +const publicBase = process.env.PUBLIC_URL || '/'; +const buildOutDir = 'build'; +const basePathPrefix = (() => { + const pathname = new URL(publicBase, 'https://example.invalid/').pathname; + return pathname === '/' ? '' : pathname.replace(/^\/+|\/+$/g, ''); +})(); +const neverPrecacheUrls = new Set(['index.html', 'version.json']); +const vitePwaManagedAssetUrls = new Set([ + 'manifest.webmanifest', + 'favicon.ico', + 'favicon2.ico', + 'robots.txt', + 'apple-touch-icon.png', + 'manifest-icon-192x192.png', + 'manifest-icon-512x512.png', +]); +const baselineAppShellUrls = new Set([ + 'registerSW.js', + 'manifest.json', + 'manifest.webmanifest', + 'favicon.ico', + 'favicon2.ico', + 'robots.txt', + 'apple-touch-icon.png', + 'manifest-icon-192x192.png', + 'manifest-icon-512x512.png', +]); + +function normalizePrecacheUrl(url) { + const normalizedUrl = url.split('?')[0].replace(/^[./]+/, ''); + + if (basePathPrefix && normalizedUrl.startsWith(`${basePathPrefix}/`)) { + return normalizedUrl.slice(basePathPrefix.length + 1); + } + + return normalizedUrl; +} + +function collectIndexAssetUrls() { + const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8'); + const urls = new Set(baselineAppShellUrls); + const assetAttributePattern = /\b(?:href|src)=["'](?:\.\/|\/)?([^"']+\.(?:css|ico|js|json|png|webmanifest))(?:\?[^"']*)?["']/g; + + for (const match of indexHtml.matchAll(assetAttributePattern)) { + urls.add(normalizePrecacheUrl(match[1])); + } + + return urls; +} + +function keepAppShellPrecacheOnly(manifestEntries) { + const appShellUrls = collectIndexAssetUrls(); + const manifest = manifestEntries.filter((entry) => { + const normalizedUrl = normalizePrecacheUrl(entry.url); + + if (neverPrecacheUrls.has(normalizedUrl)) { + return false; + } + + if (vitePwaManagedAssetUrls.has(normalizedUrl)) { + return false; + } + + return appShellUrls.has(normalizedUrl); + }); + + return { manifest }; +} function appVersionMetadataPlugin() { const payload = `${JSON.stringify({ version: appVersion })}\n`; @@ -85,7 +153,8 @@ export default defineConfig({ strategies: 'injectManifest', injectManifest: { maximumFileSizeToCacheInBytes: 20000000, - globIgnores: ['**/version.json'], + globPatterns: ['**/*.{css,html,ico,js,json,png,webmanifest}'], + manifestTransforms: [keepAppShellPrecacheOnly], }, srcDir: 'src', filename: 'sw.ts', @@ -103,17 +172,17 @@ export default defineConfig({ display: 'standalone', icons: [ { - src: '/android-chrome-192x192.png', + src: 'manifest-icon-192x192.png', sizes: '192x192', type: 'image/png', }, { - src: '/android-chrome-512x512.png', + src: 'manifest-icon-512x512.png', sizes: '512x512', type: 'image/png', }, { - src: '/android-chrome-512x512.png', + src: 'manifest-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable', @@ -216,7 +285,7 @@ export default defineConfig({ }, build: { // Use 'build' to match what electron/main.js expects (../build/index.html) - outDir: 'build', + outDir: buildOutDir, emptyOutDir: true, sourcemap: process.env.GENERATE_SOURCEMAP === 'true', target: process.env.ELECTRON ? 'electron-renderer' : 'esnext', @@ -248,7 +317,7 @@ export default defineConfig({ }, }, }, - base: process.env.PUBLIC_URL || '/', + base: publicBase, optimizeDeps: { include: ['ethers', 'assert', 'buffer', 'process', 'util', 'stream-browserify', 'isomorphic-fetch', 'workbox-core', 'workbox-precaching'], }, diff --git a/yarn.lock b/yarn.lock index c84a6f0d..6c368e23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -90,7 +90,10 @@ __metadata: wait-on: "npm:9.0.3" workbox-build: "npm:7.4.0" workbox-core: "npm:7.4.0" + workbox-expiration: "npm:7.4.0" workbox-precaching: "npm:7.4.0" + workbox-routing: "npm:7.4.0" + workbox-strategies: "npm:7.4.0" workbox-window: "npm:7.4.0" zustand: "npm:4.4.3" dependenciesMeta: