forked from baron/baron-sso
Merge branch 'dev' into feature/tenant-user-list-ui-improvement
This commit is contained in:
48
test/userfront_loading_performance_policy_test.sh
Normal file
48
test/userfront_loading_performance_policy_test.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/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 "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
|
||||
|
||||
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"
|
||||
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
|
||||
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"
|
||||
@@ -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));
|
||||
|
||||
@@ -45,17 +52,46 @@ const server = createServer((req, res) => {
|
||||
}
|
||||
|
||||
let filePath = candidate;
|
||||
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;
|
||||
}
|
||||
|
||||
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 +99,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}`);
|
||||
});
|
||||
|
||||
193
userfront-e2e/tests/login-performance-budget.spec.ts
Normal file
193
userfront-e2e/tests/login-performance-budget.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page, type Request } from '@playwright/test';
|
||||
|
||||
type LoadMetrics = {
|
||||
durationMs: number;
|
||||
transferredBytes: number;
|
||||
requestedUrls: string[];
|
||||
requestedPathCounts: Map<string, number>;
|
||||
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 requestedPathCounts = new Map<string, number>();
|
||||
const cacheControlByPath = new Map<string, string>();
|
||||
const contentEncodingByPath = new Map<string, string>();
|
||||
let transferredBytes = 0;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onResponse = 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;
|
||||
}
|
||||
};
|
||||
|
||||
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', () => {
|
||||
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(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,
|
||||
);
|
||||
expect(
|
||||
warm.requestedUrls.some((url) =>
|
||||
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,
|
||||
...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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -7,15 +7,21 @@ COPY . .
|
||||
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;"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:async';
|
||||
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/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 +89,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 +152,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) {
|
||||
@@ -192,23 +188,8 @@ 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();
|
||||
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 +533,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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,47 @@ 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";
|
||||
|
||||
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";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
187
userfront/scripts/optimize-web-build.mjs
Normal file
187
userfront/scripts/optimize-web-build.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
import { brotliCompressSync, constants } from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
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';
|
||||
|
||||
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',
|
||||
'.js',
|
||||
'.json',
|
||||
'.mjs',
|
||||
'.svg',
|
||||
'.toml',
|
||||
'.wasm',
|
||||
]);
|
||||
|
||||
if (!existsSync(bootstrapPath)) {
|
||||
throw new Error(`Missing Flutter bootstrap file: ${bootstrapPath}`);
|
||||
}
|
||||
|
||||
let bootstrap = readFileSync(bootstrapPath, 'utf8');
|
||||
const hashedEntrypoints = new Map();
|
||||
const activeEntrypointFiles = new Set();
|
||||
|
||||
for (const entrypoint of hashableEntrypoints) {
|
||||
const sourceName = findEntrypointSource(entrypoint, bootstrap);
|
||||
if (!sourceName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = join(buildDir, sourceName);
|
||||
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);
|
||||
|
||||
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 = [
|
||||
'<link rel="preload" href="flutter_bootstrap.js" as="script" />',
|
||||
hashedEntrypoints.has('main.dart.mjs')
|
||||
? `<link rel="modulepreload" href="${hashedEntrypoints.get('main.dart.mjs')}" />`
|
||||
: '',
|
||||
hashedEntrypoints.has('main.dart.wasm')
|
||||
? `<link rel="preload" href="${hashedEntrypoints.get('main.dart.wasm')}" as="fetch" type="application/wasm" crossorigin />`
|
||||
: '',
|
||||
'<link rel="modulepreload" href="canvaskit/skwasm.js" />',
|
||||
'<link rel="preload" href="canvaskit/skwasm.wasm" as="fetch" type="application/wasm" crossorigin />',
|
||||
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
|
||||
...loginFontFallbackPreloads.map(
|
||||
(href) =>
|
||||
`<link rel="preload" href="${href}" as="fetch" type="font/woff2" crossorigin />`,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
index = index
|
||||
.replace(/\n\s*<link rel="preload" href="flutter_bootstrap\.js" as="script" \/>/g, '')
|
||||
.replace(/\n\s*<link rel="modulepreload" href="main\.dart\.[^"]+\.mjs" \/>/g, '')
|
||||
.replace(
|
||||
/\n\s*<link rel="preload" href="main\.dart\.[^"]+\.wasm" as="fetch" type="application\/wasm" crossorigin \/>/g,
|
||||
'',
|
||||
)
|
||||
.replace(/\n\s*<link rel="modulepreload" href="canvaskit\/skwasm\.js" \/>/g, '')
|
||||
.replace(
|
||||
/\n\s*<link rel="preload" href="canvaskit\/skwasm\.wasm" as="fetch" type="application\/wasm" crossorigin \/>/g,
|
||||
'',
|
||||
)
|
||||
.replace(/\n\s*<link rel="preconnect" href="https:\/\/fonts\.gstatic\.com" crossorigin \/>/g, '')
|
||||
.replace(
|
||||
/\n\s*<link rel="preload" href="https:\/\/fonts\.gstatic\.com\/s\/(?:roboto|notosanskr)\/[^"]+\.woff2" as="fetch" type="font\/woff2" crossorigin \/>/g,
|
||||
'',
|
||||
);
|
||||
index = index.replace('</head>', ` ${preloadLinks}\n </head>`);
|
||||
writeFileSync(indexPath, index);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
12
userfront/test/app_theme_default_font_test.dart
Normal file
12
userfront/test/app_theme_default_font_test.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user