diff --git a/test/userfront_loading_performance_policy_test.sh b/test/userfront_loading_performance_policy_test.sh index 65bdd568..39380591 100644 --- a/test/userfront_loading_performance_policy_test.sh +++ b/test/userfront_loading_performance_policy_test.sh @@ -40,9 +40,58 @@ rg -q "brotli_static\s+on;" userfront/nginx.conf || fail "nginx must serve pre-c rg -q "brotliCompressSync" userfront/scripts/optimize-web-build.mjs || fail "Docker build optimization must generate brotli assets" rg -q "modulepreload" userfront/scripts/optimize-web-build.mjs || fail "Docker build optimization must preload wasm module entrypoints" rg -q "canvasKitBaseUrl:\"canvaskit/\"" userfront/scripts/optimize-web-build.mjs || fail "userfront web build must force local CanvasKit instead of fetching engine resources from a CDN" -rg -q "_flutter\.loader\.load\(\{config:\{canvasKitBaseUrl:\"canvaskit/\"\}\}\);" userfront/scripts/optimize-web-build.mjs || fail "Flutter service worker registration must be removed from cold path" +rg -q "serviceWorkerSettings" userfront/scripts/optimize-web-build.mjs || fail "Flutter service worker registration must be preserved so deployed clients can update cached bundles" +rg -q "serviceWorkerUrl" userfront/scripts/optimize-web-build.mjs || fail "Flutter service worker URL must be explicit so new clients register the worker" if rg -n "gzip|gzipSync|\\.gz" userfront/nginx.conf userfront/scripts/optimize-web-build.mjs; then fail "userfront web compression must be managed as brotli-only" fi rg -q "Cache-Control.*no-cache" userfront/nginx.conf || fail "HTML/app shell must use no-cache revalidation" rg -q "Cache-Control.*immutable" userfront/nginx.conf || fail "versioned static assets must use immutable cache" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +cat > "$tmp_dir/flutter_bootstrap.js" <<'BOOTSTRAP' +const serviceWorkerVersion = "e2e-policy"; +_flutter.buildConfig = { + builds: [ + { + mainJsPath: "main.dart.js", + mainWasmPath: "main.dart.wasm", + jsSupportRuntimePath: "main.dart.mjs", + }, + ], +}; +_flutter.loader.load({ + serviceWorkerSettings: { + serviceWorkerVersion: serviceWorkerVersion, + } +}); +BOOTSTRAP +cat > "$tmp_dir/index.html" <<'HTML' + + +
+ + +HTML +printf 'console.log("js");' > "$tmp_dir/main.dart.js" +printf 'console.log("mjs");' > "$tmp_dir/main.dart.mjs" +printf 'wasm' > "$tmp_dir/main.dart.wasm" + +node userfront/scripts/optimize-web-build.mjs "$tmp_dir" >/dev/null +node userfront/scripts/optimize-web-build.mjs "$tmp_dir" >/dev/null + +rg -q "serviceWorkerSettings" "$tmp_dir/flutter_bootstrap.js" || fail "optimized bootstrap must keep Flutter service worker settings" +rg -q "serviceWorkerUrl" "$tmp_dir/flutter_bootstrap.js" || fail "optimized bootstrap must register the Flutter service worker on new clients" +rg -q "canvasKitBaseUrl:\"canvaskit/\"" "$tmp_dir/flutter_bootstrap.js" || fail "optimized bootstrap must keep local CanvasKit config" +rg -q "caches\\.open" "$tmp_dir/flutter_service_worker.js" || fail "optimized service worker must cache built assets" +rg -q "networkFirst" "$tmp_dir/flutter_service_worker.js" || fail "optimized service worker must revalidate app shell assets" +if rg -n "unregister\\(" "$tmp_dir/flutter_service_worker.js"; then + fail "optimized service worker must not unregister itself" +fi +test "$(rg -o "serviceWorkerUrl" "$tmp_dir/flutter_bootstrap.js" | wc -l)" -eq 1 || fail "optimized bootstrap must not duplicate serviceWorkerUrl" +test "$(rg -o "config:\\{canvasKitBaseUrl" "$tmp_dir/flutter_bootstrap.js" | wc -l)" -eq 1 || fail "optimized bootstrap must not duplicate loader config" +rg -q "main\\.dart\\.[0-9a-f]{12}\\.mjs" "$tmp_dir/index.html" || fail "optimized index must preload hashed module entrypoint" +test ! -e "$tmp_dir/main.dart.mjs" || fail "plain module entrypoint must be renamed after hashing" +test "$(find "$tmp_dir" -maxdepth 1 -name 'main.dart.*.mjs' | wc -l)" -eq 1 || fail "exactly one hashed module entrypoint must be produced" diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts index a77fac2f..f6a29745 100644 --- a/userfront-e2e/tests/login-performance-budget.spec.ts +++ b/userfront-e2e/tests/login-performance-budget.spec.ts @@ -138,12 +138,6 @@ test.describe('UserFront login performance budget', () => { expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000); expectNoDuplicateStaticRequests(cold); expectNoDuplicateStaticRequests(warm); - expect( - cold.requestedUrls.some((url) => - url.endsWith('/flutter_service_worker.js'), - ), - ).toBe(false); - const cacheControlByPath = new Map([ ...cold.cacheControlByPath, ...warm.cacheControlByPath, @@ -155,6 +149,41 @@ test.describe('UserFront login performance budget', () => { const appShellCache = cacheControlByPath.get('/ko/signin') ?? ''; expect(appShellCache).toContain('no-cache'); + const serviceWorkerState = await page.evaluate(async () => { + if (!('serviceWorker' in navigator)) { + return { + available: false, + secure: window.isSecureContext, + scriptUrl: '', + }; + } + const registrations = await navigator.serviceWorker.getRegistrations(); + const registration = registrations[0]; + return { + available: true, + secure: window.isSecureContext, + count: registrations.length, + controller: navigator.serviceWorker.controller?.scriptURL ?? '', + scriptUrl: + registration?.active?.scriptURL ?? + registration?.waiting?.scriptURL ?? + registration?.installing?.scriptURL ?? + '', + }; + }); + if (testInfo.project.name.includes('mobile')) { + expect(new URL(serviceWorkerState.scriptUrl).pathname).toBe( + '/flutter_service_worker.js', + ); + const serviceWorkerResponse = await page.context().request.get( + new URL('/flutter_service_worker.js', page.url()).toString(), + ); + expect(serviceWorkerResponse.headers()['cache-control'] ?? '').toContain( + 'no-cache', + ); + } else { + expect(serviceWorkerState.scriptUrl).toBe(''); + } expect(cold.durationMs).toBeGreaterThanOrEqual(0); }); diff --git a/userfront/scripts/optimize-web-build.mjs b/userfront/scripts/optimize-web-build.mjs index c168dfbd..0f6099da 100644 --- a/userfront/scripts/optimize-web-build.mjs +++ b/userfront/scripts/optimize-web-build.mjs @@ -8,7 +8,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs'; -import { basename, extname, join } from 'node:path'; +import { basename, extname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; @@ -39,6 +39,7 @@ const compressibleExtensions = new Set([ '.toml', '.wasm', ]); +const serviceWorkerPath = join(buildDir, 'flutter_service_worker.js'); if (!existsSync(bootstrapPath)) { throw new Error(`Missing Flutter bootstrap file: ${bootstrapPath}`); @@ -84,17 +85,30 @@ for (const fileName of readdirSync(buildDir)) { } } +const canvasKitConfig = 'config:{canvasKitBaseUrl:"canvaskit/"}'; + bootstrap = bootstrap.replace( - /_flutter\.loader\.load\(\{\s*serviceWorkerSettings:\s*\{[\s\S]*?\}\s*\}\);/, - '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', + /_flutter\.loader\.load\(\{\s*serviceWorkerSettings:\s*(\{[^{}]*\})\s*,\s*config:\s*\{[\s\S]*?serviceWorkerUrl[\s\S]*?\}\s*,\s*config:\s*\{[^}]*\}\s*\}\);/g, + (_match, settings) => + `_flutter.loader.load({serviceWorkerSettings:${ensureServiceWorkerUrl(settings)},${canvasKitConfig}});`, ); bootstrap = bootstrap.replace( - /_flutter\.loader\.load\(\);/, - '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', + /_flutter\.loader\.load\(\{\s*serviceWorkerSettings:\s*(\{[^{}]*\})\s*,\s*config:\s*\{[^}]*\}\s*\}\);/g, + (_match, settings) => + `_flutter.loader.load({serviceWorkerSettings:${ensureServiceWorkerUrl(settings)},${canvasKitConfig}});`, ); bootstrap = bootstrap.replace( - /_flutter\.loader\.load\(\{config:\{[^}]*\}\}\);/, - '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', + /_flutter\.loader\.load\(\{\s*serviceWorkerSettings:\s*(\{[^{}]*\})\s*\}\);/g, + (_match, settings) => + `_flutter.loader.load({serviceWorkerSettings:${ensureServiceWorkerUrl(settings)},${canvasKitConfig}});`, +); +bootstrap = bootstrap.replace( + /_flutter\.loader\.load\(\);/g, + `_flutter.loader.load({${canvasKitConfig}});`, +); +bootstrap = bootstrap.replace( + /_flutter\.loader\.load\(\{config:\{[^}]*\}\}\);/g, + `_flutter.loader.load({${canvasKitConfig}});`, ); writeFileSync(bootstrapPath, bootstrap); @@ -140,6 +154,8 @@ if (existsSync(indexPath)) { writeFileSync(indexPath, index); } +writeFileSync(serviceWorkerPath, createServiceWorker()); + for (const filePath of walk(buildDir)) { if (filePath.endsWith('.br')) { continue; @@ -184,4 +200,147 @@ function findEntrypointSource(entrypoint, bootstrap) { return match?.[0] ?? null; } +function createServiceWorker() { + const assets = []; + const versionHash = createHash('sha256'); + + for (const filePath of walk(buildDir)) { + if (filePath.endsWith('.br') || filePath === serviceWorkerPath) { + continue; + } + const extension = extname(filePath); + if ( + !compressibleExtensions.has(extension) && + !['.ico', '.png', '.webp', '.woff', '.woff2'].includes(extension) + ) { + continue; + } + + const assetPath = `/${relative(buildDir, filePath).replaceAll('\\', '/')}`; + assets.push(assetPath); + versionHash.update(assetPath); + versionHash.update(readFileSync(filePath)); + } + + assets.sort(); + const version = versionHash.digest('hex').slice(0, 16); + const serializedAssets = JSON.stringify(assets, null, 2); + + return `'use strict'; + +const CACHE_NAME = 'baron-userfront-${version}'; +const CORE_ASSETS = ${serializedAssets}; +const NETWORK_FIRST_PATHS = new Set([ + '/', + '/index.html', + '/flutter_bootstrap.js', + '/version.json', + '/manifest.json', +]); + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(CORE_ASSETS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) { + return; + } + + if (isAppShellPath(url) || NETWORK_FIRST_PATHS.has(url.pathname)) { + event.respondWith(networkFirst(request, '/index.html')); + return; + } + + event.respondWith(cacheFirst(request)); +}); + +function isAppShellPath(url) { + return !url.pathname.split('/').pop().includes('.'); +} + +async function networkFirst(request, fallbackPath) { + const cache = await caches.open(CACHE_NAME); + try { + const response = await fetch(request); + if (response.ok) { + await cache.put(request, response.clone()); + } + return response; + } catch (error) { + return ( + (await cache.match(request)) ?? + (fallbackPath ? await cache.match(fallbackPath) : undefined) ?? + Response.error() + ); + } +} + +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + await cache.put(request, response.clone()); + } + return response; +} +`; +} + +function ensureServiceWorkerUrl(settings) { + const serviceWorkerUrl = `"/flutter_service_worker.js?v=" + ${serviceWorkerVersionExpression(settings)}`; + if (/serviceWorkerUrl\s*:/.test(settings)) { + return settings.replace( + /serviceWorkerUrl\s*:\s*[^,\n}]+,?/, + `serviceWorkerUrl: ${serviceWorkerUrl},`, + ); + } + + const closingBraceIndex = settings.lastIndexOf('}'); + if (closingBraceIndex < 0) { + return settings; + } + const beforeClosing = settings.slice(0, closingBraceIndex).trimEnd(); + const afterClosing = settings.slice(closingBraceIndex); + const separator = + beforeClosing.endsWith('{') || beforeClosing.endsWith(',') ? '' : ','; + return `${beforeClosing}${separator} + serviceWorkerUrl: ${serviceWorkerUrl}, + ${afterClosing}`; +} + +function serviceWorkerVersionExpression(settings) { + const match = settings.match(/serviceWorkerVersion\s*:\s*([^,\n}]+)/); + return ( + match?.[1]?.replace(/\/\*[\s\S]*?\*\//g, '').trim() ?? + 'serviceWorkerVersion' + ); +} + console.log(`[userfront] optimized ${basename(buildDir)} with hashed entrypoints and brotli assets`);