1
0
forked from baron/baron-sso

perf(userfront): optimize login web loading

This commit is contained in:
2026-05-15 14:16:34 +09:00
parent 57456bd4cd
commit 4346f48bbe
12 changed files with 383 additions and 62 deletions

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
fail() {
echo "[userfront-loading-policy] $*" >&2
exit 1
}
if rg -n "FontLoader|assets/fonts/NotoSansKR|_loadBundledFonts" userfront/lib userfront/pubspec.yaml; then
fail "userfront must not block first render on bundled NotoSansKR font loading"
fi
if rg -n "fontFamily:\s*['\"]NotoSansKR['\"]" userfront/lib; then
fail "userfront theme must use the platform default font"
fi
if rg -n "await ThemeController\.(app|auth)\.restore" userfront/lib/main.dart; then
fail "theme restore must not block the first render"
fi
if rg -n "fonts\.googleapis\.com/icon\?family=Material\+Icons" userfront/web/index.html; then
fail "userfront must not load Google Material Icons stylesheet on the login critical path"
fi
if rg -n -- "--no-tree-shake-icons" userfront/Dockerfile userfront-e2e/package.json; then
fail "userfront web release build must allow icon tree shaking"
fi
rg -q "optimize-web-build\.mjs" userfront/Dockerfile || fail "Docker build must hash and pre-compress Flutter web entrypoints"
rg -q "nginx-mod-http-brotli" userfront/Dockerfile || fail "runtime image must install the nginx Brotli module"
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"
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"

View File

@@ -45,17 +45,39 @@ const server = createServer((req, res) => {
}
let filePath = candidate;
let servesAppShellFallback = false;
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
// Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리
filePath = join(root, 'index.html');
servesAppShellFallback = true;
}
const acceptsBrotli = /\bbr\b/.test(req.headers['accept-encoding'] ?? '');
const brotliPath = `${filePath}.br`;
const servedPath = acceptsBrotli && existsSync(brotliPath) ? brotliPath : filePath;
const ext = extname(filePath);
const contentType = contentTypes[ext] ?? 'application/octet-stream';
const stats = statSync(servedPath);
const etag = `"${stats.size.toString(16)}-${Math.trunc(stats.mtimeMs).toString(16)}"`;
const cacheControl = cacheControlFor(pathname, filePath, servesAppShellFallback);
res.setHeader('Content-Type', contentType);
createReadStream(filePath)
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', stats.mtime.toUTCString());
res.setHeader('Cache-Control', cacheControl);
res.setHeader('Vary', 'Accept-Encoding');
if (servedPath === brotliPath) {
res.setHeader('Content-Encoding', 'br');
}
if (req.headers['if-none-match'] === etag) {
res.statusCode = 304;
res.end();
return;
}
createReadStream(servedPath)
.on('error', () => {
res.statusCode = 500;
res.end('Internal Server Error');
@@ -63,6 +85,39 @@ const server = createServer((req, res) => {
.pipe(res);
});
function cacheControlFor(pathname, filePath, servesAppShellFallback) {
const basename = filePath.split('/').pop() ?? '';
if (
servesAppShellFallback ||
basename === 'index.html' ||
basename === 'flutter_bootstrap.js' ||
basename === 'flutter_service_worker.js' ||
basename === 'version.json' ||
basename === 'manifest.json'
) {
return 'no-cache, max-age=0, must-revalidate';
}
if (/^\/canvaskit\/.*\.(?:js|wasm)$/i.test(pathname)) {
return 'public, max-age=31536000, immutable';
}
if (/^\/main\.dart\.[0-9a-f]{12}\.(?:js|mjs|wasm)$/i.test(pathname)) {
return 'public, max-age=31536000, immutable';
}
if (/\.(?:png|ico|svg|webp|woff|woff2)$/i.test(pathname)) {
return 'public, max-age=31536000, immutable';
}
if (/\.(?:js|css|json|mjs|wasm)$/i.test(pathname)) {
return 'no-cache, max-age=0, must-revalidate';
}
return 'no-cache, max-age=0, must-revalidate';
}
server.listen(port, '127.0.0.1', () => {
console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`);
});

View File

@@ -0,0 +1,116 @@
import { expect, test, type Page, type Request } from '@playwright/test';
type LoadMetrics = {
durationMs: number;
transferredBytes: number;
requestedUrls: string[];
cacheControlByPath: Map<string, string>;
contentEncodingByPath: Map<string, string>;
};
async function mockPublicApis(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith('/api/v1/user/me')) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
const requestedUrls: string[] = [];
const cacheControlByPath = new Map<string, string>();
const contentEncodingByPath = new Map<string, string>();
let transferredBytes = 0;
page.on('request', (request: Request) => {
requestedUrls.push(request.url());
});
page.on('response', async (response) => {
const url = new URL(response.url());
const cacheControl = response.headers()['cache-control'];
if (cacheControl) {
cacheControlByPath.set(url.pathname, cacheControl);
}
const contentEncoding = response.headers()['content-encoding'];
if (contentEncoding) {
contentEncodingByPath.set(url.pathname, contentEncoding);
}
const timing = response.request().timing();
if (timing.responseEnd >= 0) {
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,
};
}
test.describe('UserFront login performance budget', () => {
test('warm login page load stays within the two second budget and reuses cached assets', async ({
page,
}) => {
await mockPublicApis(page);
const cold = await measureSigninLoad(page);
const warm = await measureSigninLoad(page);
console.log(
`[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`,
);
expect(warm.durationMs).toBeLessThanOrEqual(2000);
expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000);
expect(warm.requestedUrls.some((url) => url.includes('NotoSansKR'))).toBe(
false,
);
expect(
warm.requestedUrls.some((url) =>
url.includes('fonts.googleapis.com/icon?family=Material+Icons'),
),
).toBe(false);
const cacheControlByPath = new Map([
...cold.cacheControlByPath,
...warm.cacheControlByPath,
]);
const contentEncodingByPath = new Map([
...cold.contentEncodingByPath,
...warm.contentEncodingByPath,
]);
const appShellCache = cacheControlByPath.get('/ko/signin') ?? '';
expect(appShellCache).toContain('no-cache');
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
const brotliEntrypoint = [...contentEncodingByPath.entries()].some(
([path, encoding]) =>
/^\/main\.dart\.[0-9a-f]{12}\.(?:mjs|wasm|js)$/.test(path) &&
encoding === 'br',
);
expect(brotliEntrypoint).toBe(true);
});
});

View File

@@ -8,14 +8,21 @@ RUN /bin/sh ./scripts/sync_userfront_locales.sh
WORKDIR /app/userfront
RUN flutter pub get
RUN touch .env
RUN flutter build web --release --no-tree-shake-icons --wasm
RUN flutter build web --release --wasm
FROM node:24-alpine AS optimize
WORKDIR /work
COPY --from=build /app/userfront/build/web /work/build/web
COPY userfront/scripts/optimize-web-build.mjs /work/scripts/optimize-web-build.mjs
RUN node /work/scripts/optimize-web-build.mjs /work/build/web
# Stage 2: Serve with Nginx
FROM nginx:alpine
FROM alpine:3.23
RUN apk add --no-cache nginx nginx-mod-http-brotli
# Copy built assets
COPY --from=build /app/userfront/build/web /usr/share/nginx/html
COPY --from=optimize /work/build/web /usr/share/nginx/html
# Copy custom Nginx config
COPY userfront/nginx.conf /etc/nginx/conf.d/default.conf
COPY userfront/nginx.conf /etc/nginx/http.d/default.conf
EXPOSE 5000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -31,6 +31,22 @@ class LocaleRegistry {
static final Set<String> _localeCodes = <String>{};
static bool _initialized = false;
static void primeWithDefaults({
Iterable<String> localeCodes = const ['en', 'ko'],
}) {
if (_localeCodes.isNotEmpty) {
return;
}
_localeCodes.addAll(
localeCodes
.map((code) => code.toLowerCase().replaceAll('_', '-'))
.where(_isValidLocaleCode),
);
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
}
static Future<void> initialize({AssetBundle? assetBundle}) async {
if (_initialized) {
return;

View File

@@ -42,11 +42,7 @@ ThemeData buildDarkTheme() {
ThemeData _buildTheme(ColorScheme colorScheme) {
final isDark = colorScheme.brightness == Brightness.dark;
final base = ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: 'NotoSansKR',
);
final base = ThemeData(useMaterial3: true, colorScheme: colorScheme);
return base.copyWith(
scaffoldBackgroundColor: colorScheme.surfaceContainerLowest,

View File

@@ -1,4 +1,5 @@
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
@@ -7,7 +8,6 @@ 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/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'features/auth/presentation/login_screen.dart';
import 'features/auth/presentation/signup_screen.dart';
@@ -90,18 +90,6 @@ void _attemptRecoveryFromNullCheck({
webWindow.redirectTo(target);
}
Future<void> _loadBundledFonts() async {
const family = 'NotoSansKR';
final loader = FontLoader(family);
try {
loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Regular.ttf'));
loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Bold.ttf'));
await loader.load();
} catch (e) {
_log.warning("Failed to preload bundled fonts: $e");
}
}
Future<void> _silentSessionRecovery() async {
_log.info("[SessionRecovery] Starting silent session recovery check...");
@@ -165,11 +153,20 @@ Future<void> _silentSessionRecovery() async {
}
}
bool _shouldRunStartupSessionRecovery(Uri uri) {
final requestedLocale = extractLocaleFromPath(uri);
if (requestedLocale == null) {
return true;
}
final path = stripLocalePath(uri);
return !isPublicAuthPath(path, uri);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
await EasyLocalization.ensureInitialized();
await LocaleRegistry.initialize();
LocaleRegistry.primeWithDefaults();
// 1. Global Error Handling
FlutterError.onError = (details) {
@@ -201,14 +198,6 @@ void main() async {
// 0. Initialize Logger
LoggerService.init();
await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
// 2. Silent Session Recovery (from cookies)
await _silentSessionRecovery();
runApp(
// URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
@@ -552,6 +541,12 @@ class _BaronSSOAppState extends State<BaronSSOApp> {
// Re-run router redirects after the first frame so session-only web
// storage state is reflected even when startup routing evaluated too early.
AuthNotifier.instance.notify();
unawaited(LocaleRegistry.initialize());
unawaited(ThemeController.app.restore());
unawaited(ThemeController.auth.restore());
if (_shouldRunStartupSessionRecovery(Uri.base)) {
unawaited(_silentSessionRecovery());
}
});
}

View File

@@ -21,11 +21,15 @@ log_format json_combined escape=json
server {
listen 5000;
root /usr/share/nginx/html;
index index.html;
include /etc/nginx/mime.types;
types {
application/javascript mjs;
application/wasm wasm;
}
etag on;
brotli off;
brotli_static on;
error_log /dev/stderr warn;
access_log /var/log/nginx/access.log json_combined;
@@ -40,35 +44,43 @@ server {
}
# --- UserFront Static Files ---
# Disable cache for all static files to ensure updates are reflected immediately
location ~* \.(js|css|html|json|mjs|wasm)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
# 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";
try_files /index.html =404;
}
location ~* ^/(flutter_bootstrap\.js|flutter_service_worker\.js|version\.json|manifest\.json)$ {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
# dart2wasm 엔트리포인트는 module 스크립트(.mjs)로 로드되므로
# MIME이 정확히 내려가지 않으면 브라우저가 로딩을 차단합니다.
location ~* \.mjs$ {
root /usr/share/nginx/html;
default_type application/javascript;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
# Flutter engine files are SDK-versioned by the build and are safe to keep warm.
location ~* ^/canvaskit/.*\.(js|wasm)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# dart2wasm 바이너리 MIME 명시
location ~* \.wasm$ {
root /usr/share/nginx/html;
default_type application/wasm;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
# App entrypoints use content hashes after the Docker build optimization step.
location ~* "^/main\.dart\.[0-9a-f]{12}\.(js|mjs|wasm)$" {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~* \.(png|ico|svg|webp|woff|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Non-hashed app entrypoints keep browser cache validation without serving stale code after deploy.
location ~* \.(js|css|json|mjs|wasm)$ {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location / {
root /usr/share/nginx/html;
index index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri $uri/ /index.html;
}
}

View File

@@ -105,9 +105,3 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
fonts:
- family: NotoSansKR
fonts:
- asset: assets/fonts/NotoSansKR-Regular.ttf
- asset: assets/fonts/NotoSansKR-Bold.ttf
weight: 700

View File

@@ -0,0 +1,81 @@
import { brotliCompressSync, constants } from 'node:zlib';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
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 hashableEntrypoints = ['main.dart.js', 'main.dart.mjs', 'main.dart.wasm'];
const compressibleExtensions = new Set([
'.css',
'.html',
'.js',
'.json',
'.mjs',
'.svg',
'.toml',
'.wasm',
]);
if (!existsSync(bootstrapPath)) {
throw new Error(`Missing Flutter bootstrap file: ${bootstrapPath}`);
}
let bootstrap = readFileSync(bootstrapPath, 'utf8');
for (const entrypoint of hashableEntrypoints) {
const sourcePath = join(buildDir, entrypoint);
if (!existsSync(sourcePath)) {
continue;
}
const content = readFileSync(sourcePath);
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12);
const extension = extname(entrypoint);
const stem = entrypoint.slice(0, -extension.length);
const hashedName = `${stem}.${hash}${extension}`;
const targetPath = join(buildDir, hashedName);
renameSync(sourcePath, targetPath);
bootstrap = bootstrap.replaceAll(entrypoint, hashedName);
}
writeFileSync(bootstrapPath, bootstrap);
for (const filePath of walk(buildDir)) {
if (filePath.endsWith('.br')) {
continue;
}
if (!compressibleExtensions.has(extname(filePath))) {
continue;
}
const content = readFileSync(filePath);
writeFileSync(
`${filePath}.br`,
brotliCompressSync(content, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
},
}),
);
}
function* walk(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
yield* walk(entryPath);
continue;
}
if (entry.isFile()) {
yield entryPath;
}
}
}
console.log(`[userfront] optimized ${basename(buildDir)} with hashed entrypoints and brotli assets`);

View File

@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/theme/app_theme.dart';
void main() {
test('themes use the platform default font family', () {
final lightTheme = buildLightTheme();
final darkTheme = buildDarkTheme();
expect(lightTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR'));
expect(darkTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR'));
});
}

View File

@@ -31,10 +31,6 @@
<title>Baron 로그인</title>
<link rel="manifest" href="manifest.json" />
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>