perf(pwa): reduce precache asset fanout

Limit PWA precache to app shell assets and add bounded runtime caching for scoped deployments.
This commit is contained in:
Tommaso Casaburi
2026-04-20 22:51:31 +07:00
committed by GitHub
parent c046c542b6
commit 80e0cabf01
5 changed files with 112 additions and 9 deletions
+4 -1
View File
@@ -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.
+3
View File
@@ -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": {
+27 -2
View File
@@ -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<RequestDestination>(['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();
+75 -6
View File
@@ -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'],
},
+3
View File
@@ -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: