diff --git a/test/userfront_loading_performance_policy_test.sh b/test/userfront_loading_performance_policy_test.sh index 85dee518..65bdd568 100644 --- a/test/userfront_loading_performance_policy_test.sh +++ b/test/userfront_loading_performance_policy_test.sh @@ -13,6 +13,10 @@ if rg -n "FontLoader|assets/fonts/NotoSansKR|_loadBundledFonts" userfront/lib us fail "userfront must not block first render on bundled NotoSansKR font loading" fi +if rg -n "dotenv\.load|touch \.env" userfront/lib/main.dart userfront/Dockerfile; then + fail "userfront web startup must not request or create public .env assets" +fi + if rg -n "fontFamily:\s*['\"]NotoSansKR['\"]" userfront/lib; then fail "userfront theme must use the platform default font" fi @@ -34,6 +38,9 @@ rg -q "nginx-mod-http-brotli" userfront/Dockerfile || fail "runtime image must i rg -Fq "main\\.dart\\.[0-9a-f]{12}" userfront/nginx.conf || fail "hashed app entrypoints must use immutable cache" rg -q "brotli_static\s+on;" userfront/nginx.conf || fail "nginx must serve pre-compressed brotli assets" 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" 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 diff --git a/userfront-e2e/scripts/serve-userfront-build.mjs b/userfront-e2e/scripts/serve-userfront-build.mjs index 73ab126d..0b8e5f85 100644 --- a/userfront-e2e/scripts/serve-userfront-build.mjs +++ b/userfront-e2e/scripts/serve-userfront-build.mjs @@ -35,6 +35,13 @@ const contentTypes = { const server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); const pathname = decodeURIComponent(url.pathname); + if (pathname === '/') { + res.statusCode = 302; + res.setHeader('Location', '/ko/signin'); + res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate'); + res.end(); + return; + } const relative = pathname === '/' ? '/index.html' : pathname; const candidate = normalize(join(root, relative)); @@ -48,6 +55,13 @@ const server = createServer((req, res) => { let servesAppShellFallback = false; if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + if (extname(pathname)) { + res.statusCode = 404; + res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate'); + res.end('Not Found'); + return; + } + // Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리 filePath = join(root, 'index.html'); servesAppShellFallback = true; diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts index e913b982..aeaa5d28 100644 --- a/userfront-e2e/tests/login-performance-budget.spec.ts +++ b/userfront-e2e/tests/login-performance-budget.spec.ts @@ -4,6 +4,7 @@ type LoadMetrics = { durationMs: number; transferredBytes: number; requestedUrls: string[]; + requestedPathCounts: Map; cacheControlByPath: Map; contentEncodingByPath: Map; }; @@ -30,15 +31,24 @@ async function mockPublicApis(page: Page): Promise { async function measureSigninLoad(page: Page): Promise { const requestedUrls: string[] = []; + const requestedPathCounts = new Map(); const cacheControlByPath = new Map(); const contentEncodingByPath = new Map(); let transferredBytes = 0; - page.on('request', (request: Request) => { + const onRequest = (request: Request) => { + const requestUrl = new URL(request.url()); requestedUrls.push(request.url()); - }); + if (requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:') { + const resourceKey = `${requestUrl.origin}${requestUrl.pathname}`; + requestedPathCounts.set( + resourceKey, + (requestedPathCounts.get(resourceKey) ?? 0) + 1, + ); + } + }; - page.on('response', async (response) => { + const onResponse = async (response) => { const url = new URL(response.url()); const cacheControl = response.headers()['cache-control']; if (cacheControl) { @@ -54,20 +64,44 @@ async function measureSigninLoad(page: Page): Promise { const sizes = await response.request().sizes().catch(() => null); transferredBytes += sizes?.responseBodySize ?? 0; } - }); - - const start = performance.now(); - await page.goto('/ko/signin', { waitUntil: 'networkidle' }); - await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); - const durationMs = Math.round(performance.now() - start); - - return { - durationMs, - transferredBytes, - requestedUrls, - cacheControlByPath, - contentEncodingByPath, }; + + page.on('request', onRequest); + page.on('response', onResponse); + + try { + const start = performance.now(); + await page.goto('/ko/signin', { waitUntil: 'networkidle' }); + await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); + const durationMs = Math.round(performance.now() - start); + + return { + durationMs, + transferredBytes, + requestedUrls, + requestedPathCounts, + cacheControlByPath, + contentEncodingByPath, + }; + } finally { + page.off('request', onRequest); + page.off('response', onResponse); + } +} + +function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void { + const duplicates = [...metrics.requestedPathCounts.entries()].filter( + ([resourceKey, count]) => { + const path = new URL(resourceKey).pathname; + return ( + count > 1 && + !path.startsWith('/api/') && + !path.endsWith('/ko/signin') && + !path.endsWith('/') + ); + }, + ); + expect(duplicates).toEqual([]); } test.describe('UserFront login performance budget', () => { @@ -82,8 +116,11 @@ test.describe('UserFront login performance budget', () => { `[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`, ); - expect(warm.durationMs).toBeLessThanOrEqual(2000); + expect(cold.durationMs).toBeLessThanOrEqual(1500); + expect(warm.durationMs).toBeLessThanOrEqual(1100); expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000); + expectNoDuplicateStaticRequests(cold); + expectNoDuplicateStaticRequests(warm); expect(warm.requestedUrls.some((url) => url.includes('NotoSansKR'))).toBe( false, ); @@ -92,6 +129,19 @@ test.describe('UserFront login performance budget', () => { url.includes('fonts.googleapis.com/icon?family=Material+Icons'), ), ).toBe(false); + expect( + [...cold.requestedUrls, ...warm.requestedUrls].some((url) => + url.includes('www.gstatic.com/flutter-canvaskit'), + ), + ).toBe(false); + expect(warm.requestedUrls.some((url) => url.endsWith('/assets/.env'))).toBe( + false, + ); + expect( + cold.requestedUrls.some((url) => + url.endsWith('/flutter_service_worker.js'), + ), + ).toBe(false); const cacheControlByPath = new Map([ ...cold.cacheControlByPath, @@ -112,5 +162,32 @@ test.describe('UserFront login performance budget', () => { encoding === 'br', ); expect(brotliEntrypoint).toBe(true); + expect(contentEncodingByPath.get('/canvaskit/skwasm.wasm')).toBe('br'); + }); + + test('root redirects to localized signin before Flutter boots', async ({ + page, + }) => { + await mockPublicApis(page); + + const requestedUrls: string[] = []; + page.on('request', (request) => { + requestedUrls.push(request.url()); + }); + + const start = performance.now(); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); + const durationMs = Math.round(performance.now() - start); + + expect(durationMs).toBeLessThanOrEqual(300); + const rootIndex = requestedUrls.findIndex( + (url) => new URL(url).pathname === '/', + ); + const bootstrapIndex = requestedUrls.findIndex((url) => + new URL(url).pathname.endsWith('/flutter_bootstrap.js'), + ); + expect(rootIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThan(rootIndex); }); }); diff --git a/userfront/Dockerfile b/userfront/Dockerfile index 785a86fa..4bc2c939 100644 --- a/userfront/Dockerfile +++ b/userfront/Dockerfile @@ -7,7 +7,6 @@ COPY . . RUN /bin/sh ./scripts/sync_userfront_locales.sh WORKDIR /app/userfront RUN flutter pub get -RUN touch .env RUN flutter build web --release --wasm FROM node:24-alpine AS optimize diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 98153f6a..c35f45f6 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:easy_localization/easy_localization.dart' hide tr; import 'package:go_router/go_router.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -189,13 +188,6 @@ void main() async { return true; }; - // .env가 없더라도 초기화 상태를 보장하도록 optional 로딩 - try { - await dotenv.load(fileName: ".env", isOptional: true); - } catch (e) { - _log.warning("Warning: .env file load failed: $e"); - } - // 0. Initialize Logger LoggerService.init(); diff --git a/userfront/nginx.conf b/userfront/nginx.conf index 3ae6f2c9..2df5fa0f 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -45,6 +45,10 @@ server { # --- UserFront Static Files --- + location = / { + return 302 /ko/signin; + } + # App shell and Flutter bootstrap files must revalidate on each deployment. location = /index.html { add_header Cache-Control "no-cache, max-age=0, must-revalidate"; diff --git a/userfront/scripts/optimize-web-build.mjs b/userfront/scripts/optimize-web-build.mjs index 7717fba6..c168dfbd 100644 --- a/userfront/scripts/optimize-web-build.mjs +++ b/userfront/scripts/optimize-web-build.mjs @@ -1,6 +1,13 @@ import { brotliCompressSync, constants } from 'node:zlib'; import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'; +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { basename, extname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; @@ -9,7 +16,19 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const buildDir = process.argv[2] ?? join(__dirname, '..', 'build', 'web'); const bootstrapPath = join(buildDir, 'flutter_bootstrap.js'); +const indexPath = join(buildDir, 'index.html'); const hashableEntrypoints = ['main.dart.js', 'main.dart.mjs', 'main.dart.wasm']; +const loginFontFallbackPreloads = [ + 'https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me4GZLCzYlKw.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2', + 'https://fonts.gstatic.com/s/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2', +]; const compressibleExtensions = new Set([ '.css', '.html', @@ -26,9 +45,16 @@ if (!existsSync(bootstrapPath)) { } let bootstrap = readFileSync(bootstrapPath, 'utf8'); +const hashedEntrypoints = new Map(); +const activeEntrypointFiles = new Set(); for (const entrypoint of hashableEntrypoints) { - const sourcePath = join(buildDir, entrypoint); + const sourceName = findEntrypointSource(entrypoint, bootstrap); + if (!sourceName) { + continue; + } + + const sourcePath = join(buildDir, sourceName); if (!existsSync(sourcePath)) { continue; } @@ -40,12 +66,80 @@ for (const entrypoint of hashableEntrypoints) { const hashedName = `${stem}.${hash}${extension}`; const targetPath = join(buildDir, hashedName); - renameSync(sourcePath, targetPath); + if (sourceName !== hashedName) { + renameSync(sourcePath, targetPath); + } + bootstrap = bootstrap.replaceAll(sourceName, hashedName); bootstrap = bootstrap.replaceAll(entrypoint, hashedName); + hashedEntrypoints.set(entrypoint, hashedName); + activeEntrypointFiles.add(hashedName); } +for (const fileName of readdirSync(buildDir)) { + if ( + /^main\.dart\.[0-9a-f]{12}\.(?:js|mjs|wasm)(?:\.br)?$/.test(fileName) && + !activeEntrypointFiles.has(fileName.replace(/\.br$/, '')) + ) { + unlinkSync(join(buildDir, fileName)); + } +} + +bootstrap = bootstrap.replace( + /_flutter\.loader\.load\(\{\s*serviceWorkerSettings:\s*\{[\s\S]*?\}\s*\}\);/, + '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', +); +bootstrap = bootstrap.replace( + /_flutter\.loader\.load\(\);/, + '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', +); +bootstrap = bootstrap.replace( + /_flutter\.loader\.load\(\{config:\{[^}]*\}\}\);/, + '_flutter.loader.load({config:{canvasKitBaseUrl:"canvaskit/"}});', +); writeFileSync(bootstrapPath, bootstrap); +if (existsSync(indexPath)) { + let index = readFileSync(indexPath, 'utf8'); + const preloadLinks = [ + '', + hashedEntrypoints.has('main.dart.mjs') + ? `` + : '', + hashedEntrypoints.has('main.dart.wasm') + ? `` + : '', + '', + '', + '', + ...loginFontFallbackPreloads.map( + (href) => + ``, + ), + ] + .filter(Boolean) + .join('\n '); + + index = index + .replace(/\n\s*/g, '') + .replace(/\n\s*/g, '') + .replace( + /\n\s*/g, + '', + ) + .replace(/\n\s*/g, '') + .replace( + /\n\s*/g, + '', + ) + .replace(/\n\s*/g, '') + .replace( + /\n\s*/g, + '', + ); + index = index.replace('', ` ${preloadLinks}\n `); + writeFileSync(indexPath, index); +} + for (const filePath of walk(buildDir)) { if (filePath.endsWith('.br')) { continue; @@ -78,4 +172,16 @@ function* walk(directory) { } } +function findEntrypointSource(entrypoint, bootstrap) { + if (existsSync(join(buildDir, entrypoint))) { + return entrypoint; + } + + const extension = extname(entrypoint).replace('.', ''); + const match = bootstrap.match( + new RegExp(`main\\.dart\\.[0-9a-f]{12}\\.${extension}`), + ); + return match?.[0] ?? null; +} + console.log(`[userfront] optimized ${basename(buildDir)} with hashed entrypoints and brotli assets`);