@@ -49,6 +49,18 @@ const METRIC_THRESHOLDS: Record<string, [good: number, poor: number]> = {
4949 */
5050const TOTAL_WEB_VITALS = 5 ;
5151
52+ /**
53+ * Minimal Web Vitals API contract used by this monitor.
54+ * Supports module import and global CDN exposure (`window.webVitals`).
55+ */
56+ type WebVitalsApi = {
57+ onCLS : ( callback : ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) => void ) => void ;
58+ onINP : ( callback : ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) => void ) => void ;
59+ onLCP : ( callback : ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) => void ) => void ;
60+ onFCP : ( callback : ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) => void ) => void ;
61+ onTTFB : ( callback : ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) => void ) => void ;
62+ } ;
63+
5264/**
5365 * Performance issues monitor handles:
5466 * - Long Tasks
@@ -246,10 +258,15 @@ export class PerformanceIssuesMonitor {
246258 * Observe Web Vitals and emit a single aggregated poor report.
247259 * Reports when all metrics are collected or on timeout/pagehide fallback.
248260 *
261+ * Resolution strategy:
262+ * 1) Use global `window.webVitals` when available (CDN scenario)
263+ * 2) Fallback to dynamic import of `web-vitals` (NPM/ESM scenario)
264+ * 3) Log warning if neither is available
265+ *
249266 * @param onIssue issue callback
250267 */
251268 private observeWebVitals ( onIssue : ( event : PerformanceIssueEvent ) => void ) : void {
252- void import ( 'web-vitals' ) . then ( ( { onCLS, onINP, onLCP, onFCP, onTTFB } ) => {
269+ void resolveWebVitalsApi ( ) . then ( ( { onCLS, onINP, onLCP, onFCP, onTTFB } ) => {
253270 if ( this . destroyed ) {
254271 return ;
255272 }
@@ -277,6 +294,8 @@ export class PerformanceIssuesMonitor {
277294 * Tries to emit an aggregated Web Vitals issue.
278295 * When `force` is false, waits for all vitals.
279296 * When `force` is true, emits with currently collected metrics.
297+ *
298+ * @param force
280299 */
281300 const tryReport = ( force : boolean ) : void => {
282301 if ( this . destroyed || reported ) {
@@ -327,6 +346,8 @@ export class PerformanceIssuesMonitor {
327346
328347 /**
329348 * Collects latest metric snapshot per metric name.
349+ *
350+ * @param metric
330351 */
331352 const collect = ( metric : { name : string ; value : number ; rating : WebVitalRating ; delta : number } ) : void => {
332353 if ( this . destroyed || reported ) {
@@ -362,19 +383,19 @@ export class PerformanceIssuesMonitor {
362383 onLCP ( collect ) ;
363384 onFCP ( collect ) ;
364385 onTTFB ( collect ) ;
365- } ) . catch ( ( ) => {
366- log (
367- 'web-vitals package is required for Web Vitals tracking. Install it with: npm i web-vitals' ,
368- 'warn'
369- ) ;
370- } ) ;
386+ } )
387+ . catch ( ( ) => {
388+ log (
389+ 'Web Vitals tracking requires `web-vitals` (npm) or global `window.webVitals` (CDN).' ,
390+ 'warn'
391+ ) ;
392+ } ) ;
371393 }
372394}
373395
374396/**
375397 * Checks if browser supports a performance entry type.
376398 *
377- *
378399 * @param type performance entry type
379400 */
380401function supportsEntryType ( type : string ) : boolean {
@@ -392,7 +413,6 @@ function supportsEntryType(type: string): boolean {
392413/**
393414 * Resolves threshold from user input and applies global minimum clamp.
394415 *
395- *
396416 * @param value custom threshold
397417 * @param fallback default threshold
398418 */
@@ -408,7 +428,6 @@ function resolveThreshold(value: number | undefined, fallback: number): number {
408428 * Returns custom threshold from detector config object.
409429 * Boolean options use default threshold.
410430 *
411- *
412431 * @param value detector config value
413432 */
414433function resolveThresholdOption ( value : boolean | { thresholdMs ?: number } ) : number | undefined {
@@ -419,10 +438,47 @@ function resolveThresholdOption(value: boolean | { thresholdMs?: number }): numb
419438 return undefined ;
420439}
421440
441+ /**
442+ * Resolves Web Vitals API from global scope or dynamic module import.
443+ */
444+ async function resolveWebVitalsApi ( ) : Promise < WebVitalsApi > {
445+ const globalApi = getGlobalWebVitalsApi ( ) ;
446+
447+ if ( globalApi ) {
448+ return globalApi ;
449+ }
450+
451+ const moduleApi = await import ( 'web-vitals' ) ;
452+
453+ return moduleApi as unknown as WebVitalsApi ;
454+ }
455+
456+ /**
457+ * Returns global Web Vitals API when Hawk is used via CDN.
458+ */
459+ function getGlobalWebVitalsApi ( ) : WebVitalsApi | null {
460+ if ( typeof globalThis === 'undefined' ) {
461+ return null ;
462+ }
463+
464+ const candidate = ( globalThis as { webVitals ?: unknown } ) . webVitals ;
465+
466+ if ( ! candidate || typeof candidate !== 'object' ) {
467+ return null ;
468+ }
469+
470+ const api = candidate as Partial < WebVitalsApi > ;
471+
472+ if ( ! api . onCLS || ! api . onINP || ! api . onLCP || ! api . onFCP || ! api . onTTFB ) {
473+ return null ;
474+ }
475+
476+ return api as WebVitalsApi ;
477+ }
478+
422479/**
423480 * Formats Web Vitals metric value for readable summary.
424481 *
425- *
426482 * @param name metric name
427483 * @param value metric value
428484 */
@@ -433,7 +489,6 @@ function formatValue(name: string, value: number): string {
433489/**
434490 * Serializes LoAF script timing into compact JSON payload.
435491 *
436- *
437492 * @param script loaf script entry
438493 */
439494function serializeScript ( script : LoAFScript ) : Json {
@@ -454,7 +509,6 @@ function serializeScript(script: LoAFScript): Json {
454509/**
455510 * Serializes aggregated Web Vitals report into event context payload.
456511 *
457- *
458512 * @param report aggregated vitals report
459513 */
460514function serializeWebVitalsReport ( report : WebVitalsReport ) : Json {
0 commit comments