- 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>
131 lines
3.6 KiB
TypeScript
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();
|
|
}
|