Files
goodgo-platform/apps/web/lib/web-vitals.ts
Ho Ngoc Hai 1ebdc5f0b3 fix: auth cookies cross-origin, async params, CSRF/web-vitals errors
- Set SameSite=lax for auth & CSRF cookies in development (cross-port)
- Set refresh_token cookie path to / (was /auth, preventing cross-port send)
- Await params in Next.js 15 async server components (layout, listings, agents)
- Add CSRF token to web-vitals POST requests
- Fix: 401 Unauthorized on all authenticated API calls from web app
- Fix: CSRF token missing on POST requests from different port
- Fix: params.locale sync access warning in generateMetadata

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-04-13 11:24:45 +07:00

131 lines
3.6 KiB
TypeScript

import type { Metric } from 'web-vitals';
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
/**
* Baseline targets for Core Web Vitals:
* LCP < 2.5s (good), < 4.0s (needs improvement)
* FCP < 1.8s (good), < 3.0s (needs improvement)
* CLS < 0.1 (good), < 0.25 (needs improvement)
* TTFB < 800ms (good)
* INP < 200ms (good), < 500ms (needs improvement)
*/
export const WEB_VITALS_THRESHOLDS = {
LCP: { good: 2500, poor: 4000 },
FCP: { good: 1800, poor: 3000 },
CLS: { good: 0.1, poor: 0.25 },
TTFB: { good: 800, poor: 1800 },
INP: { good: 200, poor: 500 },
} as const;
type VitalName = keyof typeof WEB_VITALS_THRESHOLDS;
function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const thresholds = WEB_VITALS_THRESHOLDS[name as VitalName];
if (!thresholds) return 'good';
if (value <= thresholds.good) return 'good';
if (value <= thresholds.poor) return 'needs-improvement';
return 'poor';
}
interface WebVitalPayload {
name: string;
value: number;
rating: string;
delta: number;
id: string;
navigationType: string;
url: string;
timestamp: number;
}
/** Queue metrics and flush in batches to avoid per-metric requests. */
const queue: WebVitalPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 5000;
const MAX_BATCH_SIZE = 10;
function getCsrfToken(): string | undefined {
if (typeof document === 'undefined') return undefined;
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
}
function flushQueue(): void {
if (queue.length === 0) return;
const batch = queue.splice(0, MAX_BATCH_SIZE);
const body = JSON.stringify({ metrics: batch });
// Use sendBeacon for reliability during page unload; fall back to fetch
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
const sent = navigator.sendBeacon(`${API_BASE_URL}/web-vitals`, blob);
if (!sent) {
sendViaFetch(body);
}
} else {
sendViaFetch(body);
}
}
function sendViaFetch(body: string): void {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const csrfToken = getCsrfToken();
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
fetch(`${API_BASE_URL}/web-vitals`, {
method: 'POST',
headers,
credentials: 'include',
body,
keepalive: true,
}).catch(() => {
// Silently drop — metrics are best-effort
});
}
function enqueue(payload: WebVitalPayload): void {
queue.push(payload);
if (queue.length >= MAX_BATCH_SIZE) {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = null;
flushQueue();
return;
}
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushQueue();
}, FLUSH_INTERVAL_MS);
}
}
/** Report a single web-vitals Metric to the backend. */
export function reportWebVital(metric: Metric): void {
const payload: WebVitalPayload = {
name: metric.name,
value: metric.value,
rating: metric.rating ?? getRating(metric.name, metric.value),
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType ?? 'unknown',
url: typeof location !== 'undefined' ? location.pathname : '',
timestamp: Date.now(),
};
enqueue(payload);
}
/** Flush any remaining metrics (call on page hide / unload). */
export function flushWebVitals(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushQueue();
}