diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41182a1..df17dfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,40 @@ jobs: name: unit-test-report path: reports/unit/ if-no-files-found: ignore + + pwa-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable corepack (installs pnpm from packageManager field) + run: | + corepack enable + corepack prepare pnpm@10.28.2 --activate + pnpm --version + + - run: pnpm install --frozen-lockfile + - run: pnpm build + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + # First-install / offline reload, SW update activation & cache migration, + # and bounded precache storage-pressure coverage. + - name: Run PWA E2E tests + run: pnpm test:pwa + env: + CI: true + + - name: Upload PWA test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pwa-test-report + path: playwright-report/ + if-no-files-found: ignore diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md index e48097a..ad6c8d6 100644 --- a/docs/SECURITY_AUDIT.md +++ b/docs/SECURITY_AUDIT.md @@ -82,6 +82,7 @@ behaviour is preserved: | `shell-quote` | `1.12.0` | GHSA-pqg4-j6r4-53mv (critical), GHSA-395f-4hp3-45gv. | | `@stellar/stellar-sdk>toml` | `5.0.1` | GHSA-82x6-q7mm-w9cf, GHSA-v5mp-jgw5-2x6j, GHSA-4qx8-w7c3-pj3p, GHSA-9j65-hcg3-gc64, GHSA-9qmh-m7c6-f92p. | | `engine.io-client>ws`, `ethers>ws`, `viem>ws` | `8.22.0` | GHSA-96hv-2xvq-fx4p, GHSA-58qx-3vcg-4xpx. | +| `zod` | `4.6.6` | GHSA-mrhx-c7xc-m3w5. | Overrides that cross a declared major range are scoped to the single path that needs them with pnpm's `parent>child` syntax, so they cannot silently change a diff --git a/e2e/pwa-first-install-offline.spec.ts b/e2e/pwa-first-install-offline.spec.ts new file mode 100644 index 0000000..7d4d186 --- /dev/null +++ b/e2e/pwa-first-install-offline.spec.ts @@ -0,0 +1,249 @@ +/** + * PWA first-install → offline reload test + * + * Verifies that after the service worker installs and activates for the first + * time, the app shell (HTML + critical CSS + bootstrap JS) is served entirely + * from the precache on the very next reload even when the network is offline. + * + * This test MUST run against the production build served by `vite preview` + * (port 5174) because the SW is intentionally disabled in dev mode. + * It is placed in the pwa-chromium Playwright project for that reason. + * + * Bug history: the Workbox JS runtime route used `/^\/assets\/.*\.js$/i` which + * never matched against full `request.url` hrefs, so optional chunks were never + * cached at runtime. More critically the entry bootstrap was removed from the + * precache manifest, so the first offline reload failed because the bootstrap + * had been fetched before the new worker controlled the page. + */ + +import { expect, test } from '@playwright/test'; + +// Use a fresh browser context for every test so we never inherit a pre-existing +// service worker registration or Cache Storage entries from another spec. +test.use({ contextOptions: { storageState: undefined } }); + +test.describe('PWA precache — first install then offline reload', () => { + test('app shell loads from cache on first offline reload after SW install', async ({ + page, + context, + }) => { + // ── Step 1: online first load ──────────────────────────────────────────── + // Navigate to the app. The SW installs and activates on this visit. + await page.goto('/'); + + // Wait for the service worker to become activated. The SW uses + // 'registerType: prompt' so it does not call skipWaiting automatically; + // we trigger it programmatically via the ServiceWorker API. + await page.waitForFunction( + () => + navigator.serviceWorker.controller !== null || + navigator.serviceWorker.getRegistration().then((r) => r?.active != null), + { timeout: 20_000 }, + ); + + // Force the waiting SW to take control immediately (simulates the user + // accepting an update prompt or the first install finishing activation). + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration(); + if (!reg) throw new Error('No SW registration found'); + + // If there is a waiting worker, activate it now. + if (reg.waiting) { + reg.waiting.postMessage({ type: 'SKIP_WAITING' }); + } + + // Wait until a controller is set (i.e. the SW is active and controlling). + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { + once: true, + }); + }); + } + }); + + // Allow precache population to complete: the SW caches all injected + // manifest entries during the `install` event, so by the time the worker + // controls the page the precache should already be warm. One extra tick + // is enough. + await page.waitForTimeout(500); + + // ── Step 2: go offline ─────────────────────────────────────────────────── + await context.setOffline(true); + + // ── Step 3: reload while offline ──────────────────────────────────────── + // The page must load from the service worker's precache, not the network. + const [response] = await Promise.all([ + page.waitForResponse((res) => res.url().includes(page.url().replace(/\/$/, ''))), + page.reload(), + ]); + + // A network-served response would throw while offline; getting here means + // the SW intercepted the navigation request and served it from cache. + + // ── Step 4: verify the page rendered ──────────────────────────────────── + // The app should render without a network error page. We look for the + // root `
` being non-empty, which proves React bootstrapped. + await expect(page.locator('#root')).not.toBeEmpty({ timeout: 10_000 }); + + // ── Step 5: verify bootstrap JS was served from precache ──────────────── + // Check that at least one assets/index-*.js request was served (status 200) + // during the offline reload, which must come from the service worker's + // precache manifest. + const swPrecacheHasBootstrap = await page.evaluate(async () => { + const cacheNames = await caches.keys(); + const precacheName = cacheNames.find((name) => name.includes('precache')); + if (!precacheName) return false; + const cache = await caches.open(precacheName); + const keys = await cache.keys(); + return keys.some((req) => /\/assets\/index-[^/]+\.js$/.test(new URL(req.url).pathname)); + }); + + expect(swPrecacheHasBootstrap).toBe(true); + + // ── Step 6: verify the JS runtime route matches by pathname ───────────── + // A non-bootstrap chunk (e.g. a route chunk that was fetched during the + // initial online load) should be findable in the runtime 'assets' cache, + // proving the pathname-based route matcher works. + const swRuntimeCacheHasChunk = await page.evaluate(async () => { + const cacheNames = await caches.keys(); + const assetsCacheName = cacheNames.find((n) => n.includes('-assets-')); + if (!assetsCacheName) { + // If no extra chunks were fetched yet that's also acceptable — the + // route is correct as long as the precache bootstrap is present. + return null; + } + const cache = await caches.open(assetsCacheName); + const keys = await cache.keys(); + return keys.some((req) => new URL(req.url).pathname.startsWith('/assets/')); + }); + + // null means no extra chunk was fetched, which is fine. + // true means the runtime assets cache is working with correct pathname matching. + expect(swRuntimeCacheHasChunk).not.toBe(false); + }); + + test('offline navigation to /send serves the precached shell', async ({ page, context }) => { + // Install the SW by loading the app once online. + await page.goto('/'); + + await page.waitForFunction(() => navigator.serviceWorker.controller !== null, { + timeout: 20_000, + }); + + await context.setOffline(true); + + // Navigate to a different route while offline. The SW must serve the + // index.html shell (which handles all SPA routes via precache + fallback). + await page.goto('/send'); + + await expect(page.locator('#root')).not.toBeEmpty({ timeout: 10_000 }); + + // The page title should still include the app name (not a browser error page). + await expect(page).toHaveTitle(/Wraith/i); + }); + + test('precache is bounded to app shell and excludes optional chain chunks (storage pressure)', async ({ + page, + }) => { + await page.goto('/'); + + await page.waitForFunction(() => navigator.serviceWorker.controller !== null, { + timeout: 20_000, + }); + + const precacheStats = await page.evaluate(async () => { + const cacheNames = await caches.keys(); + const precacheName = cacheNames.find((name) => name.includes('precache')); + if (!precacheName) throw new Error('No precache found'); + const cache = await caches.open(precacheName); + const requests = await cache.keys(); + const urls = requests.map((r) => new URL(r.url).pathname); + return { + count: urls.length, + hasScannerWorker: urls.some((u) => u.includes('stellar-scanner.worker')), + hasStellarSend: urls.some((u) => u.includes('StellarSend')), + hasSolanaSend: urls.some((u) => u.includes('SolanaSend')), + hasCkbSend: urls.some((u) => u.includes('CkbSend')), + hasZxing: urls.some((u) => u.includes('zxing-browser')), + hasMetamask: urls.some((u) => u.includes('metamask-sdk')), + }; + }); + + // Ensure the precache is strictly bounded (not the 232-entry full build) + expect(precacheStats.count).toBeLessThanOrEqual(25); + // Ensure heavy/optional chain chunks and workers are excluded from precache + expect(precacheStats.hasScannerWorker).toBe(false); + expect(precacheStats.hasStellarSend).toBe(false); + expect(precacheStats.hasSolanaSend).toBe(false); + expect(precacheStats.hasCkbSend).toBe(false); + expect(precacheStats.hasZxing).toBe(false); + expect(precacheStats.hasMetamask).toBe(false); + }); + + test('service worker activation purges obsolete caches on update (storage pressure release)', async ({ + page, + context, + }) => { + // Seed obsolete versioned and legacy caches before visiting + await context.addInitScript(async () => { + await window.caches.open('wraith-precache-v1'); + await window.caches.open('wraith-assets-v1'); + await window.caches.open('wraith-images-v1'); + await window.caches.open('rpc-cache'); + }); + + await page.goto('/'); + + // Wait for the service worker registration + await page.waitForFunction( + () => + navigator.serviceWorker.controller !== null || + navigator.serviceWorker + .getRegistration() + .then((r) => r?.active != null || r?.waiting != null), + { timeout: 20_000 }, + ); + + // Force SKIP_WAITING if waiting, and wait for controller + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration(); + if (!reg) throw new Error('No SW registration found'); + + if (reg.waiting) { + reg.waiting.postMessage({ type: 'SKIP_WAITING' }); + } + + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { + once: true, + }); + }); + } + }); + + // Wait until obsolete caches are purged by activate event + await page.waitForFunction( + () => + window.caches + .keys() + .then( + (names) => + names.some((n) => n.includes('wraith-precache-v2')) && + !names.includes('wraith-precache-v1') && + !names.includes('wraith-assets-v1') && + !names.includes('rpc-cache'), + ), + null, + { timeout: 20_000 }, + ); + + const remaining = await page.evaluate(async () => caches.keys()); + expect(remaining).not.toContain('wraith-precache-v1'); + expect(remaining).not.toContain('wraith-assets-v1'); + expect(remaining).not.toContain('wraith-images-v1'); + expect(remaining).not.toContain('rpc-cache'); + expect(remaining.some((n) => n.includes('wraith-precache-v2'))).toBe(true); + }); +}); diff --git a/package.json b/package.json index 64fc9c9..5ffca0e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prepare": "husky", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:pwa": "playwright test --project=pwa-chromium", "test": "playwright test", "test:ui": "playwright test --ui", "test:unit": "vitest run", @@ -132,7 +133,8 @@ "@stellar/stellar-sdk>toml": "5.0.1", "engine.io-client>ws": "8.22.0", "ethers>ws": "8.22.0", - "viem>ws": "8.22.0" + "viem>ws": "8.22.0", + "zod": "4.6.6" } }, "packageManager": "pnpm@10.28.2" diff --git a/playwright.config.ts b/playwright.config.ts index 696b016..524f32b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,10 @@ import { defineConfig, devices } from '@playwright/test'; /** * See https://playwright.dev/docs/test-configuration. */ +const isPwaOnly = + process.argv.some((arg) => arg.includes('pwa-chromium') || arg.includes('pwa-')) || + Boolean(process.env.PWA_TEST_ONLY); + export default defineConfig({ testDir: '.', testMatch: ['**/tests/**/*.spec.ts', '**/e2e/**/*.spec.ts'], @@ -21,19 +25,55 @@ export default defineConfig({ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + testIgnore: ['**/e2e/pwa-*.spec.ts'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, + testIgnore: ['**/e2e/pwa-*.spec.ts'], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, + testIgnore: ['**/e2e/pwa-*.spec.ts'], + }, + // PWA tests require a production build (the SW is disabled in dev mode). + // They run only on Chromium, which has the most complete SW + Cache API + // support required for the offline-reload scenario. + { + name: 'pwa-chromium', + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5174', + }, + testMatch: ['**/e2e/pwa-*.spec.ts'], }, ], - webServer: { - command: 'pnpm dev', - url: 'http://127.0.0.1:5173', - reuseExistingServer: !process.env.CI, - }, + webServer: isPwaOnly + ? [ + { + // The pwa-chromium project points at the preview server. The build must + // have run before `playwright test` is invoked (pnpm build && pnpm test). + command: 'pnpm preview --port 5174 --host 127.0.0.1', + url: 'http://127.0.0.1:5174', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ] + : [ + { + command: 'pnpm dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + // The pwa-chromium project points at the preview server. The build must + // have run before `playwright test` is invoked (pnpm build && pnpm test). + command: 'pnpm preview --port 5174 --host 127.0.0.1', + url: 'http://127.0.0.1:5174', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d68b91e..6d9006e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: engine.io-client>ws: 8.22.0 ethers>ws: 8.22.0 viem>ws: 8.22.0 + zod: 4.6.6 importers: diff --git a/scripts/bundle-budget-gate.mjs b/scripts/bundle-budget-gate.mjs index 871bb4a..3e458b2 100644 --- a/scripts/bundle-budget-gate.mjs +++ b/scripts/bundle-budget-gate.mjs @@ -46,18 +46,8 @@ export class BundleBudgetError extends Error { } } -/** Extract every same-origin script/modulepreload src from built index.html. */ -export function extractInitialLoadPaths(html) { - const paths = new Set(); - const tagPattern = - /<(?:script[^>]*\stype="module"[^>]*\ssrc="([^"]+)"|link[^>]*\srel="modulepreload"[^>]*\shref="([^"]+)")/g; - let match; - while ((match = tagPattern.exec(html)) !== null) { - const src = match[1] ?? match[2]; - if (src && !/^https?:\/\//.test(src)) paths.add(src); - } - return [...paths]; -} +import { extractInitialLoadPaths } from './initial-load-paths.mjs'; +export { extractInitialLoadPaths }; async function sizeOf(distDir, relativePath) { const cleaned = relativePath.replace(/^\/+/, ''); diff --git a/scripts/initial-load-paths.mjs b/scripts/initial-load-paths.mjs new file mode 100644 index 0000000..9935ff5 --- /dev/null +++ b/scripts/initial-load-paths.mjs @@ -0,0 +1,12 @@ +/** Extract every same-origin script/modulepreload src from built index.html. */ +export function extractInitialLoadPaths(html) { + const paths = new Set(); + const tagPattern = + /<(?:script[^>]*\stype="module"[^>]*\ssrc="([^"]+)"|link[^>]*\srel="modulepreload"[^>]*\shref="([^"]+)")/g; + let match; + while ((match = tagPattern.exec(html)) !== null) { + const src = match[1] ?? match[2]; + if (src && !/^https?:\/\//.test(src)) paths.add(src); + } + return [...paths]; +} diff --git a/src/lib/swCache.test.ts b/src/lib/swCache.test.ts index 81442d7..a0bcc96 100644 --- a/src/lib/swCache.test.ts +++ b/src/lib/swCache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { + CACHE_KINDS, CACHE_PREFIX, CACHE_VERSION, CACHE_VERSION_SUFFIX, @@ -117,3 +118,22 @@ describe('activation cache migration', () => { await expect(purgeObsoleteCaches(failingDelete)).resolves.toEqual([]); }); }); + +describe('bounded precache schema (v2)', () => { + it('owns bounded runtime caches for optional chain/media assets', () => { + expect(CACHE_KINDS).toContain('assets'); + expect(CACHE_KINDS).toContain('images'); + expect(cacheName('assets')).toBe(`wraith-assets-${CACHE_VERSION_SUFFIX}`); + expect(cacheName('images')).toBe(`wraith-images-${CACHE_VERSION_SUFFIX}`); + }); + + it('purges every v1 cache on v2 activation (storage-pressure release)', () => { + const v1 = expectedCacheNames(CACHE_VERSION - 1); + const v2 = expectedCacheNames(); + + const plan = planCacheMigration([...v1, ...v2]); + + expect(plan.keep).toEqual(v2); + expect(plan.delete).toEqual(v1); + }); +}); diff --git a/src/lib/swCache.ts b/src/lib/swCache.ts index f71bf38..d35ff2e 100644 --- a/src/lib/swCache.ts +++ b/src/lib/swCache.ts @@ -34,13 +34,21 @@ export const CACHE_PREFIX = 'wraith'; * Cache schema version. Increment to invalidate every cache this app owns. * Keep in sync with the release notes: a bump is a cache migration. */ -export const CACHE_VERSION = 1; +export const CACHE_VERSION = 2; /** Suffix appended to every versioned cache name (`v1`, `v2`, …). */ export const CACHE_VERSION_SUFFIX = `v${CACHE_VERSION}`; /** Cache kinds the app owns. `precache`/`runtime` match Workbox's own names. */ -export const CACHE_KINDS = ['precache', 'runtime', 'rpc', 'fonts-styles', 'fonts-files'] as const; +export const CACHE_KINDS = [ + 'precache', + 'runtime', + 'rpc', + 'fonts-styles', + 'fonts-files', + 'assets', + 'images', +] as const; export type CacheKind = (typeof CACHE_KINDS)[number]; diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 1635594..75be120 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -1,8 +1,12 @@ /// /// -import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'; +import { + cleanupOutdatedCaches, + createHandlerBoundToURL, + precacheAndRoute, +} from 'workbox-precaching'; import { setCacheNameDetails } from 'workbox-core'; -import { registerRoute } from 'workbox-routing'; +import { NavigationRoute, registerRoute } from 'workbox-routing'; import { NetworkFirst, CacheFirst } from 'workbox-strategies'; import { ExpirationPlugin } from 'workbox-expiration'; import { CacheableResponsePlugin } from 'workbox-cacheable-response'; @@ -48,6 +52,13 @@ precacheAndRoute(self.__WB_MANIFEST); // Removes precaches carrying build-time revisions of earlier Workbox versions. cleanupOutdatedCaches(); +// ─── SPA Navigation Route ──────────────────────────────────────────────────── +// Serves the precached index.html shell for all navigation requests so +// first offline reloads and client-side route transitions (e.g. /send) work +// when offline. +const navigationHandler = createHandlerBoundToURL('index.html'); +registerRoute(new NavigationRoute(navigationHandler)); + // ─── Runtime caching ────────────────────────────────────────────────────────── const RPC_HOSTNAMES = [ 'soroban-testnet.stellar.org', @@ -92,6 +103,45 @@ registerRoute( }), ); +// ─── Runtime caching: optional chain & media assets ────────────────────────── +// The application shell (HTML, critical CSS, the entry bootstrap JS) is +// precached — see vite.config.ts globPatterns. All other JS chunks (route +// chunks, chain SDKs) and media are NOT precached. They are cached on first +// use in bounded, expiring caches so a deploy or storage pressure cannot let +// them grow without bound. Vite hashes file names by content, so CacheFirst is +// safe: a new deploy serves new URLs and automatically busts old entries. +// +// NOTE: Workbox RegExpRoute evaluates the full `request.url` href, so a +// pattern anchored with `^/` would never match `https://host/assets/file.js`. +// All same-origin asset routes therefore use a callback that tests +// `url.pathname` directly. + +// JS chunks — non-bootstrap route chunks, chain SDKs, workers. +// Excludes the entry bootstrap (assets/index-*.js) which is already precached. +registerRoute( + ({ url, sameOrigin }) => + sameOrigin && url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js'), + new CacheFirst({ + cacheName: cacheName('assets'), + plugins: [ + new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 7 * 24 * 60 * 60 }), + new CacheableResponsePlugin({ statuses: [0, 200] }), + ], + }), +); + +// Images — icons, OG image, logos, favicons not already precached. +registerRoute( + ({ url, sameOrigin }) => sameOrigin && /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i.test(url.pathname), + new CacheFirst({ + cacheName: cacheName('images'), + plugins: [ + new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 30 * 24 * 60 * 60 }), + new CacheableResponsePlugin({ statuses: [0, 200] }), + ], + }), +); + // ─── Stellar Notification Service Worker logic ──────────────────────────────── // (merged from src/sw/stellar-notification-sw.ts) diff --git a/vite.config.ts b/vite.config.ts index d2383ff..0c47bc6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; +import fs from 'fs'; import { VitePWA } from 'vite-plugin-pwa'; +import { extractInitialLoadPaths } from './scripts/initial-load-paths.mjs'; export default defineConfig({ test: { @@ -81,12 +83,52 @@ export default defineConfig({ // injectManifest config: controls what gets injected into self.__WB_MANIFEST injectManifest: { - // Precache all built assets (JS, CSS, HTML, icons, fonts) - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], - // app-sw.ts is the service worker itself, not a prefetchable asset - globIgnores: ['**/app-sw*'], - // Raise file size limit — Stellar SDK + wagmi chunks exceed 2 MiB - maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // 4 MiB + // Precache only the application shell: HTML, critical CSS, web manifest, + // and app icons. JS chunks (route chunks, chain SDKs), images, and fonts + // are left for bounded runtime caches in app-sw.ts. + // + // The critical bootstrap JS (referenced directly from index.html) is + // deterministically identified via manifestTransforms below so only the + // true bootstrap is precached, avoiding broad globs that capture heavy + // vendor or route chunks. + globPatterns: [ + 'index.html', + 'assets/*.css', + 'favicon.ico', + 'favicon-*.png', + 'apple-touch-icon.png', + 'manifest.webmanifest', + ], + // app-sw.ts is the service worker itself, not a prefetchable asset. + globIgnores: ['**/app-sw*', '**/stellar-notification-sw*'], + // Deterministically include only the critical bootstrap / app shell JS + // referenced synchronously from dist/index.html. + manifestTransforms: [ + async (manifestEntries) => { + const htmlPath = path.resolve(__dirname, 'dist/index.html'); + const html = await fs.promises.readFile(htmlPath, 'utf8'); + const initialPaths = extractInitialLoadPaths(html); + + for (const initialPath of initialPaths) { + const relPath = initialPath.replace(/^\/+/, ''); + if (manifestEntries.some((entry) => entry.url === relPath)) continue; + + const fullPath = path.resolve(__dirname, 'dist', relPath); + try { + const fileStat = await fs.promises.stat(fullPath); + manifestEntries.push({ + url: relPath, + revision: null, // Vite hashes filenames for cache-busting + size: fileStat.size, + }); + } catch { + // Ignore missing files + } + } + + return { manifest: manifestEntries }; + }, + ], }, devOptions: {