|
| 1 | +import { browser } from '$app/environment'; |
| 2 | + |
| 3 | +export interface Metric { name: string; value: number; rating: 'good' | 'needs-improvement' | 'poor' } |
| 4 | + |
| 5 | +const thresholds: Record<string, [number, number]> = { |
| 6 | + LCP: [2500, 4000], |
| 7 | + FID: [100, 300], |
| 8 | + CLS: [0.1, 0.25], |
| 9 | + FCP: [1800, 3000], |
| 10 | + TTFB: [800, 1800], |
| 11 | +}; |
| 12 | + |
| 13 | +function rate(name: string, value: number): Metric['rating'] { |
| 14 | + const [good, poor] = thresholds[name] ?? [Infinity, Infinity]; |
| 15 | + return value <= good ? 'good' : value <= poor ? 'needs-improvement' : 'poor'; |
| 16 | +} |
| 17 | + |
| 18 | +/** Collect Web Vitals via PerformanceObserver and report via callback */ |
| 19 | +export function collectWebVitals(onMetric: (m: Metric) => void) { |
| 20 | + if (!browser || !('PerformanceObserver' in window)) return; |
| 21 | + |
| 22 | + observe('largest-contentful-paint', (entries) => { |
| 23 | + const e = entries.at(-1) as PerformanceEntry & { renderTime?: number; loadTime?: number }; |
| 24 | + const value = (e.renderTime || e.loadTime) ?? 0; |
| 25 | + onMetric({ name: 'LCP', value, rating: rate('LCP', value) }); |
| 26 | + }); |
| 27 | + |
| 28 | + observe('first-input', (entries) => { |
| 29 | + const e = entries[0] as PerformanceEntry & { processingStart: number }; |
| 30 | + const value = e.processingStart - e.startTime; |
| 31 | + onMetric({ name: 'FID', value, rating: rate('FID', value) }); |
| 32 | + }); |
| 33 | + |
| 34 | + let clsValue = 0; |
| 35 | + observe('layout-shift', (entries) => { |
| 36 | + for (const e of entries as (PerformanceEntry & { hadRecentInput: boolean; value: number })[]) { |
| 37 | + if (!e.hadRecentInput) clsValue += e.value; |
| 38 | + } |
| 39 | + onMetric({ name: 'CLS', value: clsValue, rating: rate('CLS', clsValue) }); |
| 40 | + }); |
| 41 | + |
| 42 | + observe('paint', (entries) => { |
| 43 | + const fcp = entries.find((e) => e.name === 'first-contentful-paint'); |
| 44 | + if (fcp) onMetric({ name: 'FCP', value: fcp.startTime, rating: rate('FCP', fcp.startTime) }); |
| 45 | + }); |
| 46 | + |
| 47 | + observe('navigation', (entries) => { |
| 48 | + const e = entries[0] as PerformanceNavigationTiming; |
| 49 | + const value = e.responseStart - e.requestStart; |
| 50 | + onMetric({ name: 'TTFB', value, rating: rate('TTFB', value) }); |
| 51 | + }); |
| 52 | +} |
| 53 | + |
| 54 | +function observe(type: string, cb: (entries: PerformanceEntry[]) => void) { |
| 55 | + try { |
| 56 | + new PerformanceObserver((list) => cb(list.getEntries())).observe({ type, buffered: true }); |
| 57 | + } catch { /* unsupported entry type */ } |
| 58 | +} |
| 59 | + |
| 60 | +/** Measure an async operation and log duration in dev */ |
| 61 | +export async function measure<T>(label: string, fn: () => Promise<T>): Promise<T> { |
| 62 | + const start = performance.now(); |
| 63 | + const result = await fn(); |
| 64 | + if (import.meta.env.DEV) console.debug(`[perf] ${label}: ${(performance.now() - start).toFixed(1)}ms`); |
| 65 | + return result; |
| 66 | +} |
0 commit comments