Skip to content

Commit d77424c

Browse files
committed
chore: cdn support
1 parent 2906466 commit d77424c

File tree

3 files changed

+115
-14
lines changed

3 files changed

+115
-14
lines changed

packages/javascript/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ The event context contains all metrics with:
269269
- `rating`
270270
- `delta`
271271
272-
`web-vitals` is an optional peer dependency and is loaded only when `issues.webVitals: true`.
272+
`web-vitals` is optional and used only when `issues.webVitals: true`:
273+
- NPM/ESM setup: install `web-vitals` as dependency.
274+
- CDN setup: expose global `window.webVitals` before Hawk initialization.
273275
274276
### Disabling
275277

packages/javascript/src/addons/performance-issues.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ const METRIC_THRESHOLDS: Record<string, [good: number, poor: number]> = {
4949
*/
5050
const 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
*/
380401
function 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
*/
414433
function 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
*/
439494
function 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
*/
460514
function serializeWebVitalsReport(report: WebVitalsReport): Json {

packages/javascript/tests/performance-issues.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,37 @@ function mockWebVitals() {
7474
};
7575
}
7676

77+
function mockGlobalWebVitals() {
78+
const callbacks: Record<string, ReportCallback | undefined> = {};
79+
80+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
81+
(globalThis as any).webVitals = {
82+
onCLS: (cb: ReportCallback) => { callbacks.CLS = cb; },
83+
onINP: (cb: ReportCallback) => { callbacks.INP = cb; },
84+
onLCP: (cb: ReportCallback) => { callbacks.LCP = cb; },
85+
onFCP: (cb: ReportCallback) => { callbacks.FCP = cb; },
86+
onTTFB: (cb: ReportCallback) => { callbacks.TTFB = cb; },
87+
};
88+
89+
return {
90+
emit(metric: Metric): void {
91+
callbacks[metric.name]?.(metric);
92+
},
93+
cleanup(): void {
94+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95+
delete (globalThis as any).webVitals;
96+
},
97+
};
98+
}
99+
77100
describe('PerformanceIssuesMonitor', () => {
78101
beforeEach(() => {
79102
vi.resetModules();
80103
vi.unstubAllGlobals();
81104
vi.restoreAllMocks();
82105
vi.useRealTimers();
106+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
107+
delete (globalThis as any).webVitals;
83108
MockPerformanceObserver.reset();
84109
vi.stubGlobal('PerformanceObserver', MockPerformanceObserver as unknown as typeof PerformanceObserver);
85110
});
@@ -88,6 +113,8 @@ describe('PerformanceIssuesMonitor', () => {
88113
vi.unstubAllGlobals();
89114
vi.restoreAllMocks();
90115
vi.useRealTimers();
116+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117+
delete (globalThis as any).webVitals;
91118
});
92119

93120
it('should clamp long task threshold to 50ms minimum', async () => {
@@ -208,4 +235,22 @@ describe('PerformanceIssuesMonitor', () => {
208235
expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vitals');
209236
expect(onIssue.mock.calls[0][0].context).toHaveProperty('webVitals');
210237
});
238+
239+
it('should use global webVitals API when available (CDN scenario)', async () => {
240+
vi.useFakeTimers();
241+
const globalWebVitals = mockGlobalWebVitals();
242+
const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
243+
const onIssue = vi.fn();
244+
const monitor = new PerformanceIssuesMonitor();
245+
246+
monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue);
247+
await vi.dynamicImportSettled();
248+
249+
globalWebVitals.emit({ name: 'INP', value: 600, rating: 'poor', delta: 600 });
250+
vi.advanceTimersByTime(WEB_VITALS_REPORT_TIMEOUT_MS);
251+
252+
expect(onIssue).toHaveBeenCalledTimes(1);
253+
expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vitals');
254+
globalWebVitals.cleanup();
255+
});
211256
});

0 commit comments

Comments
 (0)