| 1 |
// noinspection JSUnusedGlobalSymbols |
| 2 |
|
| 3 |
/** |
| 4 |
* @typedef {import("web-vitals").LCPMetric} LCPMetric |
| 5 |
* @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution |
| 6 |
* @typedef {import("./types.ts").ElementData} ElementData |
| 7 |
* @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction |
| 8 |
* @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction |
| 9 |
* @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction |
| 10 |
* @typedef {import("./types.ts").OnINPFunction} OnINPFunction |
| 11 |
* @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction |
| 12 |
* @typedef {import("./types.ts").OnTTFBWithAttributionFunction} OnTTFBWithAttributionFunction |
| 13 |
* @typedef {import("./types.ts").OnFCPWithAttributionFunction} OnFCPWithAttributionFunction |
| 14 |
* @typedef {import("./types.ts").OnLCPWithAttributionFunction} OnLCPWithAttributionFunction |
| 15 |
* @typedef {import("./types.ts").OnINPWithAttributionFunction} OnINPWithAttributionFunction |
| 16 |
* @typedef {import("./types.ts").OnCLSWithAttributionFunction} OnCLSWithAttributionFunction |
| 17 |
* @typedef {import("./types.ts").URLMetric} URLMetric |
| 18 |
* @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus |
| 19 |
* @typedef {import("./types.ts").Extension} Extension |
| 20 |
* @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData |
| 21 |
* @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData |
| 22 |
* @typedef {import("./types.ts").GetRootDataFunction} GetRootDataFunction |
| 23 |
* @typedef {import("./types.ts").ExtendRootDataFunction} ExtendRootDataFunction |
| 24 |
* @typedef {import("./types.ts").GetElementDataFunction} GetElementDataFunction |
| 25 |
* @typedef {import("./types.ts").ExtendElementDataFunction} ExtendElementDataFunction |
| 26 |
* @typedef {import("./types.ts").Logger} Logger |
| 27 |
*/ |
| 28 |
|
| 29 |
/** |
| 30 |
* Window reference to reduce size when the script is minified. |
| 31 |
* |
| 32 |
* @type {Window} |
| 33 |
*/ |
| 34 |
const win = window; |
| 35 |
|
| 36 |
/** |
| 37 |
* Document reference to reduce size when the script is minified. |
| 38 |
* |
| 39 |
* @type {Document} |
| 40 |
*/ |
| 41 |
const doc = win.document; |
| 42 |
|
| 43 |
/** |
| 44 |
* Prefix which is prepended to all messages logged to the console. |
| 45 |
* |
| 46 |
* @see {createLogger} |
| 47 |
* @type {string} |
| 48 |
*/ |
| 49 |
const consoleLogPrefix = '[Optimization Detective]'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Session storage key for client-side storage lock to prevent clients attempting to submit URL Metrics when there is a server-side storage lock. |
| 53 |
* |
| 54 |
* @see {isStorageLocked} |
| 55 |
* @see {setStorageLock} |
| 56 |
* @type {string} |
| 57 |
*/ |
| 58 |
const storageLockTimeSessionKey = 'odStorageLockTime'; |
| 59 |
|
| 60 |
/** |
| 61 |
* Wait duration in milliseconds for debounced calls to re-compress the URL Metric JSON data. |
| 62 |
* |
| 63 |
* @see {debounceCompressUrlMetric} |
| 64 |
* @type {number} |
| 65 |
*/ |
| 66 |
const compressionDebounceWaitDuration = 1000; |
| 67 |
|
| 68 |
/** |
| 69 |
* Checks whether storage is locked. |
| 70 |
* |
| 71 |
* @param {number} currentTime - Current time in milliseconds. |
| 72 |
* @param {number} storageLockTTL - Storage lock TTL in seconds. |
| 73 |
* @return {boolean} Whether storage is locked. |
| 74 |
*/ |
| 75 |
function isStorageLocked( currentTime, storageLockTTL ) { |
| 76 |
if ( storageLockTTL === 0 ) { |
| 77 |
return false; |
| 78 |
} |
| 79 |
|
| 80 |
try { |
| 81 |
const storageLockTime = parseInt( |
| 82 |
sessionStorage.getItem( storageLockTimeSessionKey ) || '', |
| 83 |
10 |
| 84 |
); |
| 85 |
return ( |
| 86 |
! isNaN( storageLockTime ) && |
| 87 |
currentTime < storageLockTime + storageLockTTL * 1000 |
| 88 |
); |
| 89 |
} catch { |
| 90 |
return false; |
| 91 |
} |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Sets the storage lock. |
| 96 |
* |
| 97 |
* @param {number} currentTime - Current time in milliseconds. |
| 98 |
*/ |
| 99 |
function setStorageLock( currentTime ) { |
| 100 |
try { |
| 101 |
sessionStorage.setItem( |
| 102 |
storageLockTimeSessionKey, |
| 103 |
String( currentTime ) |
| 104 |
); |
| 105 |
} catch {} |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Creates a logger object with log, warn, and error methods. |
| 110 |
* |
| 111 |
* @param {boolean} [debugMode=false] - Whether all messages should be logged. If false, then only errors are logged. |
| 112 |
* @param {?string} [prefix=null] - Prefix to prepend to the console message. |
| 113 |
* @param {?string} [scriptModuleUrl=null] - The URL for the script module which is emitting the log. This is used for extensions. |
| 114 |
* @return {Logger} Logger object with log, info, warn, and error methods. |
| 115 |
*/ |
| 116 |
function createLogger( |
| 117 |
debugMode = false, |
| 118 |
prefix = null, |
| 119 |
scriptModuleUrl = null |
| 120 |
) { |
| 121 |
const logSource = scriptModuleUrl ? `\nSource: ${ scriptModuleUrl }` : null; |
| 122 |
|
| 123 |
/** |
| 124 |
* Constructs the args to pass to the logging function. |
| 125 |
* |
| 126 |
* @param {Array<any>} message - The message(s) to log. |
| 127 |
* @param {boolean} includeSource - Whether to include the source. This should be true for warnings or errors. |
| 128 |
* @return {Array<any>} Amended message. |
| 129 |
*/ |
| 130 |
const constructLogArgs = ( message, includeSource = false ) => { |
| 131 |
return [ prefix, ...message, includeSource ? logSource : null ].filter( |
| 132 |
( value ) => value !== null |
| 133 |
); |
| 134 |
}; |
| 135 |
|
| 136 |
return { |
| 137 |
/** |
| 138 |
* Logs a message if debug mode is enabled. |
| 139 |
* |
| 140 |
* @param {...*} message - The message(s) to log. |
| 141 |
*/ |
| 142 |
log( ...message ) { |
| 143 |
if ( debugMode ) { |
| 144 |
// eslint-disable-next-line no-console |
| 145 |
console.log( ...constructLogArgs( message, false ) ); |
| 146 |
} |
| 147 |
}, |
| 148 |
|
| 149 |
/** |
| 150 |
* Logs an informational message if debug mode is enabled. |
| 151 |
* |
| 152 |
* @param {...*} message - The message(s) to log as info. |
| 153 |
*/ |
| 154 |
info( ...message ) { |
| 155 |
if ( debugMode ) { |
| 156 |
// eslint-disable-next-line no-console |
| 157 |
console.info( ...constructLogArgs( message, false ) ); |
| 158 |
} |
| 159 |
}, |
| 160 |
|
| 161 |
/** |
| 162 |
* Logs a warning if debug mode is enabled. |
| 163 |
* |
| 164 |
* @param {...*} message - The message(s) to log as a warning. |
| 165 |
*/ |
| 166 |
warn( ...message ) { |
| 167 |
if ( debugMode ) { |
| 168 |
// eslint-disable-next-line no-console |
| 169 |
console.warn( ...constructLogArgs( message, true ) ); |
| 170 |
} |
| 171 |
}, |
| 172 |
|
| 173 |
/** |
| 174 |
* Logs an error. |
| 175 |
* |
| 176 |
* @param {...*} message - The message(s) to log as an error. |
| 177 |
*/ |
| 178 |
error( ...message ) { |
| 179 |
// eslint-disable-next-line no-console |
| 180 |
console.error( ...constructLogArgs( message, true ) ); |
| 181 |
}, |
| 182 |
}; |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* Attempts to get the extension name (i.e. slug for plugin or theme) from the script module URL. |
| 187 |
* |
| 188 |
* If extraction of the slug fails, then the entire URL is returned. |
| 189 |
* |
| 190 |
* @param {string} scriptModuleUrl - Script module URL. |
| 191 |
* @return {string} Derived extension name. |
| 192 |
*/ |
| 193 |
function getExtensionNameFromScriptModuleUrl( scriptModuleUrl ) { |
| 194 |
try { |
| 195 |
const url = new URL( scriptModuleUrl, win.location.href ); |
| 196 |
const matches = url.pathname.match( |
| 197 |
/\/(?:themes|plugins)\/([^\/]+)\// |
| 198 |
); |
| 199 |
if ( matches ) { |
| 200 |
return matches[ 1 ]; |
| 201 |
} |
| 202 |
return url.pathname; |
| 203 |
} catch { |
| 204 |
return scriptModuleUrl; |
| 205 |
} |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Gets the status for the URL Metric group for the provided viewport width. |
| 210 |
* |
| 211 |
* The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`. |
| 212 |
* This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`. |
| 213 |
* |
| 214 |
* @param {number} viewportWidth - Current viewport width. |
| 215 |
* @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses. |
| 216 |
* @return {URLMetricGroupStatus} The URL metric group for the viewport width. |
| 217 |
*/ |
| 218 |
function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) { |
| 219 |
for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) { |
| 220 |
if ( |
| 221 |
viewportWidth > urlMetricGroupStatus.minimumViewportWidth && |
| 222 |
( null === urlMetricGroupStatus.maximumViewportWidth || |
| 223 |
viewportWidth <= urlMetricGroupStatus.maximumViewportWidth ) |
| 224 |
) { |
| 225 |
return urlMetricGroupStatus; |
| 226 |
} |
| 227 |
} |
| 228 |
throw new Error( |
| 229 |
`${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.` |
| 230 |
); |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric. |
| 235 |
* |
| 236 |
* @param {string} currentETag - Current ETag. |
| 237 |
* @param {string} currentUrl - Current URL. |
| 238 |
* @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status. |
| 239 |
* @param {Logger} logger - Logger. |
| 240 |
* @return {Promise<string|null>} Session storage key for the current URL or null if crypto is not available or caused an error. |
| 241 |
*/ |
| 242 |
async function getAlreadySubmittedSessionStorageKey( |
| 243 |
currentETag, |
| 244 |
currentUrl, |
| 245 |
urlMetricGroupStatus, |
| 246 |
{ warn, error } |
| 247 |
) { |
| 248 |
if ( ! win.crypto || ! win.crypto.subtle ) { |
| 249 |
warn( |
| 250 |
'Unable to generate sessionStorage key for already-submitted URL since crypto is not available, likely due to to the page not being served via HTTPS.' |
| 251 |
); |
| 252 |
return null; |
| 253 |
} |
| 254 |
|
| 255 |
try { |
| 256 |
const message = [ |
| 257 |
currentETag, |
| 258 |
currentUrl, |
| 259 |
urlMetricGroupStatus.minimumViewportWidth, |
| 260 |
urlMetricGroupStatus.maximumViewportWidth || '', |
| 261 |
].join( '-' ); |
| 262 |
|
| 263 |
/* |
| 264 |
* Note that the components are hashed for a couple of reasons: |
| 265 |
* |
| 266 |
* 1. It results in a consistent length string devoid of any special characters that could cause problems. |
| 267 |
* 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is |
| 268 |
* examined to see which URLs the client went to. |
| 269 |
* |
| 270 |
* The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security. |
| 271 |
*/ |
| 272 |
const msgBuffer = new TextEncoder().encode( message ); |
| 273 |
const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer ); |
| 274 |
const hashHex = Array.from( new Uint8Array( hashBuffer ) ) |
| 275 |
.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) ) |
| 276 |
.join( '' ); |
| 277 |
return `odSubmitted-${ hashHex }`; |
| 278 |
} catch ( err ) { |
| 279 |
error( |
| 280 |
'Unable to generate sessionStorage key for already-submitted URL due to error:', |
| 281 |
err |
| 282 |
); |
| 283 |
return null; |
| 284 |
} |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Gets the current time in milliseconds. |
| 289 |
* |
| 290 |
* @return {number} Current time in milliseconds. |
| 291 |
*/ |
| 292 |
function getCurrentTime() { |
| 293 |
return Date.now(); |
| 294 |
} |
| 295 |
|
| 296 |
/** |
| 297 |
* Recursively freezes an object to prevent mutation. |
| 298 |
* |
| 299 |
* @param {Object} obj - Object to recursively freeze. |
| 300 |
*/ |
| 301 |
function recursiveFreeze( /** @type {Record<string, any>} */ obj ) { |
| 302 |
for ( const prop of Object.getOwnPropertyNames( obj ) ) { |
| 303 |
const value = obj[ prop ]; |
| 304 |
if ( null !== value && typeof value === 'object' ) { |
| 305 |
recursiveFreeze( value ); |
| 306 |
} |
| 307 |
} |
| 308 |
Object.freeze( obj ); |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* URL Metric being assembled for submission. |
| 313 |
* |
| 314 |
* @type {URLMetric} |
| 315 |
*/ |
| 316 |
let urlMetric; |
| 317 |
|
| 318 |
/** |
| 319 |
* Reserved root property keys. |
| 320 |
* |
| 321 |
* @see {URLMetric} |
| 322 |
* @see {ExtendedElementData} |
| 323 |
* @type {Set<string>} |
| 324 |
*/ |
| 325 |
const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] ); |
| 326 |
|
| 327 |
/** |
| 328 |
* Gets root URL Metric data. |
| 329 |
* |
| 330 |
* @type {GetRootDataFunction} |
| 331 |
* @return {URLMetric} URL Metric. |
| 332 |
*/ |
| 333 |
function getRootData() { |
| 334 |
const immutableUrlMetric = structuredClone( urlMetric ); |
| 335 |
recursiveFreeze( immutableUrlMetric ); |
| 336 |
return immutableUrlMetric; |
| 337 |
} |
| 338 |
|
| 339 |
/** |
| 340 |
* Extends root URL Metric data. |
| 341 |
* |
| 342 |
* @type {ExtendRootDataFunction} |
| 343 |
* @param {ExtendedRootData} properties |
| 344 |
*/ |
| 345 |
function extendRootData( properties ) { |
| 346 |
for ( const key of Object.getOwnPropertyNames( properties ) ) { |
| 347 |
if ( reservedRootPropertyKeys.has( key ) ) { |
| 348 |
throw new Error( `Disallowed setting of key '${ key }' on root.` ); |
| 349 |
} |
| 350 |
} |
| 351 |
Object.assign( urlMetric, properties ); |
| 352 |
debounceCompressUrlMetric(); |
| 353 |
} |
| 354 |
|
| 355 |
/** |
| 356 |
* Mapping of XPath to element data. |
| 357 |
* |
| 358 |
* @type {Map<string, ElementData>} |
| 359 |
*/ |
| 360 |
const elementsByXPath = new Map(); |
| 361 |
|
| 362 |
/** |
| 363 |
* Reserved element property keys. |
| 364 |
* |
| 365 |
* @see {ElementData} |
| 366 |
* @see {ExtendedRootData} |
| 367 |
* @type {Set<string>} |
| 368 |
*/ |
| 369 |
const reservedElementPropertyKeys = new Set( [ |
| 370 |
'isLCP', |
| 371 |
'isLCPCandidate', |
| 372 |
'xpath', |
| 373 |
'intersectionRatio', |
| 374 |
'intersectionRect', |
| 375 |
'boundingClientRect', |
| 376 |
] ); |
| 377 |
|
| 378 |
/** |
| 379 |
* Gets element data. |
| 380 |
* |
| 381 |
* @type {GetElementDataFunction} |
| 382 |
* @param {string} xpath - XPath. |
| 383 |
* @return {ElementData|null} Element data, or null if no element for the XPath exists. |
| 384 |
*/ |
| 385 |
function getElementData( xpath ) { |
| 386 |
const elementData = elementsByXPath.get( xpath ); |
| 387 |
if ( elementData ) { |
| 388 |
const cloned = structuredClone( elementData ); |
| 389 |
recursiveFreeze( cloned ); |
| 390 |
return cloned; |
| 391 |
} |
| 392 |
return null; |
| 393 |
} |
| 394 |
|
| 395 |
/** |
| 396 |
* Extends element data. |
| 397 |
* |
| 398 |
* @type {ExtendElementDataFunction} |
| 399 |
* @param {string} xpath - XPath. |
| 400 |
* @param {ExtendedElementData} properties - Properties. |
| 401 |
*/ |
| 402 |
function extendElementData( xpath, properties ) { |
| 403 |
if ( ! elementsByXPath.has( xpath ) ) { |
| 404 |
throw new Error( `Unknown element with XPath: ${ xpath }` ); |
| 405 |
} |
| 406 |
for ( const key of Object.getOwnPropertyNames( properties ) ) { |
| 407 |
if ( reservedElementPropertyKeys.has( key ) ) { |
| 408 |
throw new Error( |
| 409 |
`Disallowed setting of key '${ key }' on element.` |
| 410 |
); |
| 411 |
} |
| 412 |
} |
| 413 |
const elementData = elementsByXPath.get( xpath ); |
| 414 |
if ( elementData ) { |
| 415 |
Object.assign( elementData, properties ); |
| 416 |
} |
| 417 |
debounceCompressUrlMetric(); |
| 418 |
} |
| 419 |
|
| 420 |
/** |
| 421 |
* Compresses a JSON string using CompressionStream API. |
| 422 |
* |
| 423 |
* @param {string} jsonString - JSON string to compress. |
| 424 |
* @return {Promise<Blob>} Compressed data. |
| 425 |
*/ |
| 426 |
async function compress( jsonString ) { |
| 427 |
const encodedData = new TextEncoder().encode( jsonString ); |
| 428 |
const compressedDataStream = new Blob( [ encodedData ] ) |
| 429 |
.stream() |
| 430 |
.pipeThrough( new CompressionStream( 'gzip' ) ); |
| 431 |
const compressedDataBuffer = await new Response( |
| 432 |
compressedDataStream |
| 433 |
).arrayBuffer(); |
| 434 |
return new Blob( [ compressedDataBuffer ], { type: 'application/gzip' } ); |
| 435 |
} |
| 436 |
|
| 437 |
/** |
| 438 |
* The compressed URL metric data. |
| 439 |
* |
| 440 |
* @see {debounceCompressUrlMetric} |
| 441 |
* @type {?Blob} |
| 442 |
*/ |
| 443 |
let compressedPayload = null; |
| 444 |
|
| 445 |
/** |
| 446 |
* Timeout ID for debouncing URL metric compression. |
| 447 |
* |
| 448 |
* @see {debounceCompressUrlMetric} |
| 449 |
* @type {?ReturnType<typeof setTimeout>} |
| 450 |
*/ |
| 451 |
let recompressionTimeout = null; |
| 452 |
|
| 453 |
/** |
| 454 |
* Handle for requestIdleCallback for URL metric compression. |
| 455 |
* |
| 456 |
* @see {debounceCompressUrlMetric} |
| 457 |
* @type {?number} |
| 458 |
*/ |
| 459 |
let idleCallbackHandle = null; |
| 460 |
|
| 461 |
/** |
| 462 |
* Whether compression is enabled. |
| 463 |
* |
| 464 |
* @see {detect} |
| 465 |
* @see {debounceCompressUrlMetric} |
| 466 |
* @type {boolean} |
| 467 |
*/ |
| 468 |
let compressionEnabled = true; |
| 469 |
|
| 470 |
/** |
| 471 |
* Debounces the compression of the URL Metric. |
| 472 |
*/ |
| 473 |
function debounceCompressUrlMetric() { |
| 474 |
if ( ! compressionEnabled ) { |
| 475 |
return; |
| 476 |
} |
| 477 |
if ( null !== recompressionTimeout ) { |
| 478 |
clearTimeout( recompressionTimeout ); |
| 479 |
recompressionTimeout = null; |
| 480 |
} |
| 481 |
if ( |
| 482 |
null !== idleCallbackHandle && |
| 483 |
typeof cancelIdleCallback === 'function' |
| 484 |
) { |
| 485 |
cancelIdleCallback( idleCallbackHandle ); |
| 486 |
idleCallbackHandle = null; |
| 487 |
} |
| 488 |
recompressionTimeout = setTimeout( async () => { |
| 489 |
if ( typeof requestIdleCallback === 'function' ) { |
| 490 |
await new Promise( ( resolve ) => { |
| 491 |
idleCallbackHandle = requestIdleCallback( resolve ); |
| 492 |
} ); |
| 493 |
idleCallbackHandle = null; |
| 494 |
} |
| 495 |
try { |
| 496 |
compressedPayload = await compress( JSON.stringify( urlMetric ) ); |
| 497 |
} catch ( err ) { |
| 498 |
const { error } = createLogger( false, consoleLogPrefix ); |
| 499 |
error( |
| 500 |
'Failed to compress URL Metric falling back to sending uncompressed data:', |
| 501 |
err |
| 502 |
); |
| 503 |
compressionEnabled = false; |
| 504 |
} |
| 505 |
recompressionTimeout = null; |
| 506 |
}, compressionDebounceWaitDuration ); |
| 507 |
} |
| 508 |
|
| 509 |
/** |
| 510 |
* @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData |
| 511 |
* @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData |
| 512 |
*/ |
| 513 |
|
| 514 |
/** |
| 515 |
* Args for the detect function. |
| 516 |
* |
| 517 |
* @since 1.0.0 |
| 518 |
* |
| 519 |
* @typedef {Object} DetectFunctionArgs |
| 520 |
* @property {string[]} extensionModuleUrls - URLs for extension script modules to import. |
| 521 |
* @property {number} minViewportAspectRatio - Minimum aspect ratio allowed for the viewport. |
| 522 |
* @property {number} maxViewportAspectRatio - Maximum aspect ratio allowed for the viewport. |
| 523 |
* @property {boolean} isDebug - Whether to show debug messages. |
| 524 |
* @property {string} restApiEndpoint - URL for where to send the detection data. |
| 525 |
* @property {string} [restApiNonce] - Nonce for the REST API when the user is logged-in. |
| 526 |
* @property {boolean} gzdecodeAvailable - Whether application/gzip can be sent to the REST API. |
| 527 |
* @property {number} maxUrlMetricSize - Maximum size of the URL Metric to send. |
| 528 |
* @property {string} currentETag - Current ETag. |
| 529 |
* @property {string} currentUrl - Current URL. |
| 530 |
* @property {string} urlMetricSlug - Slug for URL Metric. |
| 531 |
* @property {number|null} cachePurgePostId - Cache purge post ID. |
| 532 |
* @property {string} urlMetricHMAC - HMAC for URL Metric storage. |
| 533 |
* @property {URLMetricGroupStatus[]} urlMetricGroupStatuses - URL Metric group statuses. |
| 534 |
* @property {number} storageLockTTL - The TTL (in seconds) for the URL Metric storage lock. |
| 535 |
* @property {number} freshnessTTL - The freshness age (TTL) for a given URL Metric. |
| 536 |
* @property {string} webVitalsLibrarySrc - The URL for the web-vitals library. |
| 537 |
* @property {CollectionDebugData} [urlMetricGroupCollection] - URL Metric group collection, when in debug mode. |
| 538 |
*/ |
| 539 |
|
| 540 |
/** |
| 541 |
* The detect function. |
| 542 |
* |
| 543 |
* @since 1.0.0 |
| 544 |
* @callback DetectFunction |
| 545 |
* @param {DetectFunctionArgs} args - The arguments for the function. |
| 546 |
* @return {Promise<void>} |
| 547 |
*/ |
| 548 |
|
| 549 |
/** |
| 550 |
* Detects the LCP element, loaded images, client viewport, and store for future optimizations. |
| 551 |
* |
| 552 |
* @type {DetectFunction} |
| 553 |
* @param {DetectFunctionArgs} args - Args. |
| 554 |
*/ |
| 555 |
export default async function detect( { |
| 556 |
minViewportAspectRatio, |
| 557 |
maxViewportAspectRatio, |
| 558 |
isDebug, |
| 559 |
extensionModuleUrls, |
| 560 |
restApiEndpoint, |
| 561 |
restApiNonce, |
| 562 |
gzdecodeAvailable, |
| 563 |
maxUrlMetricSize, |
| 564 |
currentETag, |
| 565 |
currentUrl, |
| 566 |
urlMetricSlug, |
| 567 |
cachePurgePostId, |
| 568 |
urlMetricHMAC, |
| 569 |
urlMetricGroupStatuses, |
| 570 |
storageLockTTL, |
| 571 |
freshnessTTL, |
| 572 |
webVitalsLibrarySrc, |
| 573 |
urlMetricGroupCollection, |
| 574 |
} ) { |
| 575 |
const logger = createLogger( isDebug, consoleLogPrefix ); |
| 576 |
const { log, warn, error } = logger; |
| 577 |
compressionEnabled = gzdecodeAvailable; |
| 578 |
|
| 579 |
if ( isDebug && Array.isArray( urlMetricGroupCollection?.groups ) ) { |
| 580 |
const allUrlMetrics = /** @type Array<UrlMetricDebugData> */ []; |
| 581 |
for ( const group of urlMetricGroupCollection.groups ) { |
| 582 |
for ( const otherUrlMetric of group.url_metrics ) { |
| 583 |
otherUrlMetric.creationDate = new Date( |
| 584 |
otherUrlMetric.timestamp * 1000 |
| 585 |
); |
| 586 |
allUrlMetrics.push( otherUrlMetric ); |
| 587 |
} |
| 588 |
} |
| 589 |
log( 'Stored URL Metric Group Collection:', urlMetricGroupCollection ); |
| 590 |
allUrlMetrics.sort( ( a, b ) => b.timestamp - a.timestamp ); |
| 591 |
log( |
| 592 |
'Stored URL Metrics in reverse chronological order:', |
| 593 |
allUrlMetrics |
| 594 |
); |
| 595 |
} |
| 596 |
|
| 597 |
if ( win.innerWidth === 0 || win.innerHeight === 0 ) { |
| 598 |
log( |
| 599 |
'Window must have non-zero dimensions for URL Metric collection.' |
| 600 |
); |
| 601 |
return; |
| 602 |
} |
| 603 |
|
| 604 |
if ( doc.visibilityState === 'hidden' && ! doc.prerendering ) { |
| 605 |
log( 'Page opened in background tab so URL Metric is not collected.' ); |
| 606 |
return; |
| 607 |
} |
| 608 |
|
| 609 |
// Abort if the current viewport is not among those which need URL Metrics. |
| 610 |
const urlMetricGroupStatus = getGroupForViewportWidth( |
| 611 |
win.innerWidth, |
| 612 |
urlMetricGroupStatuses |
| 613 |
); |
| 614 |
if ( urlMetricGroupStatus.complete ) { |
| 615 |
log( 'No need for URL Metrics from the current viewport.' ); |
| 616 |
return; |
| 617 |
} |
| 618 |
|
| 619 |
// Abort if the client already submitted a URL Metric for this URL and viewport group. |
| 620 |
const alreadySubmittedSessionStorageKey = |
| 621 |
await getAlreadySubmittedSessionStorageKey( |
| 622 |
currentETag, |
| 623 |
currentUrl, |
| 624 |
urlMetricGroupStatus, |
| 625 |
logger |
| 626 |
); |
| 627 |
if ( |
| 628 |
null !== alreadySubmittedSessionStorageKey && |
| 629 |
alreadySubmittedSessionStorageKey in sessionStorage |
| 630 |
) { |
| 631 |
const previousVisitTime = parseInt( |
| 632 |
sessionStorage.getItem( alreadySubmittedSessionStorageKey ) || '', |
| 633 |
10 |
| 634 |
); |
| 635 |
if ( |
| 636 |
! isNaN( previousVisitTime ) && |
| 637 |
( freshnessTTL < 0 || |
| 638 |
( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL ) |
| 639 |
) { |
| 640 |
log( |
| 641 |
'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.' |
| 642 |
); |
| 643 |
return; |
| 644 |
} |
| 645 |
} |
| 646 |
|
| 647 |
// Abort if the viewport aspect ratio is not in a common range. |
| 648 |
const aspectRatio = win.innerWidth / win.innerHeight; |
| 649 |
if ( |
| 650 |
aspectRatio < minViewportAspectRatio || |
| 651 |
aspectRatio > maxViewportAspectRatio |
| 652 |
) { |
| 653 |
warn( |
| 654 |
`Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.` |
| 655 |
); |
| 656 |
return; |
| 657 |
} |
| 658 |
|
| 659 |
// TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition? |
| 660 |
// As an alternative to this, the od_print_detection_script() function can short-circuit if the |
| 661 |
// od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could |
| 662 |
// result in metrics missed from being gathered when a user navigates around a site and primes the page cache. |
| 663 |
if ( isStorageLocked( getCurrentTime(), storageLockTTL ) ) { |
| 664 |
warn( 'Aborted detection due to storage being locked.' ); |
| 665 |
return; |
| 666 |
} |
| 667 |
|
| 668 |
// Keep track of whether the window resized. If it was resized, we abort sending the URLMetric. |
| 669 |
let didWindowResize = false; |
| 670 |
win.addEventListener( |
| 671 |
'resize', |
| 672 |
() => { |
| 673 |
didWindowResize = true; |
| 674 |
}, |
| 675 |
{ once: true } |
| 676 |
); |
| 677 |
|
| 678 |
const { |
| 679 |
/** @type {OnTTFBFunction|OnTTFBWithAttributionFunction} */ onTTFB, |
| 680 |
/** @type {OnFCPFunction|OnFCPWithAttributionFunction} */ onFCP, |
| 681 |
/** @type {OnLCPFunction|OnLCPWithAttributionFunction} */ onLCP, |
| 682 |
/** @type {OnINPFunction|OnINPWithAttributionFunction} */ onINP, |
| 683 |
/** @type {OnCLSFunction|OnCLSWithAttributionFunction} */ onCLS, |
| 684 |
} = await import( webVitalsLibrarySrc ); |
| 685 |
|
| 686 |
// TODO: Does this make sense here? |
| 687 |
// Prevent detection when page is not scrolled to the initial viewport. |
| 688 |
if ( doc.documentElement.scrollTop > 0 ) { |
| 689 |
warn( |
| 690 |
'Aborted detection since initial scroll position of page is not at the top.' |
| 691 |
); |
| 692 |
return; |
| 693 |
} |
| 694 |
|
| 695 |
log( 'Proceeding with detection' ); |
| 696 |
|
| 697 |
const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' ); |
| 698 |
|
| 699 |
/** @type {Map<Element, string>} */ |
| 700 |
const breadcrumbedElementsMap = new Map( |
| 701 |
[ ...breadcrumbedElements ].map( |
| 702 |
/** |
| 703 |
* @param {Element} element |
| 704 |
* @return {[Element, string]} Tuple of an element and its XPath. |
| 705 |
*/ |
| 706 |
( element ) => [ |
| 707 |
element, |
| 708 |
/** @type {string} */ ( |
| 709 |
element.getAttribute( 'data-od-xpath' ) |
| 710 |
), |
| 711 |
] |
| 712 |
) |
| 713 |
); |
| 714 |
|
| 715 |
/** @type {IntersectionObserverEntry[]} */ |
| 716 |
const elementIntersections = []; |
| 717 |
|
| 718 |
/** @type {?IntersectionObserver} */ |
| 719 |
let intersectionObserver; |
| 720 |
|
| 721 |
function disconnectIntersectionObserver() { |
| 722 |
if ( intersectionObserver instanceof IntersectionObserver ) { |
| 723 |
intersectionObserver.disconnect(); |
| 724 |
win.removeEventListener( 'scroll', disconnectIntersectionObserver ); // Clean up, even though this is registered with once:true. |
| 725 |
} |
| 726 |
} |
| 727 |
|
| 728 |
// Wait for the intersection observer to report back on the initially visible elements. |
| 729 |
// Note that the first callback will include _all_ observed entries per <https://github.com/w3c/IntersectionObserver/issues/476>. |
| 730 |
if ( breadcrumbedElementsMap.size > 0 ) { |
| 731 |
await /** @type {Promise<void>} */ ( |
| 732 |
new Promise( ( resolve ) => { |
| 733 |
intersectionObserver = new IntersectionObserver( |
| 734 |
( entries ) => { |
| 735 |
for ( const entry of entries ) { |
| 736 |
elementIntersections.push( entry ); |
| 737 |
} |
| 738 |
resolve(); |
| 739 |
}, |
| 740 |
{ |
| 741 |
root: null, // To watch for intersection relative to the device's viewport. |
| 742 |
threshold: 0.0, // As soon as even one pixel is visible. |
| 743 |
} |
| 744 |
); |
| 745 |
|
| 746 |
for ( const element of breadcrumbedElementsMap.keys() ) { |
| 747 |
intersectionObserver.observe( element ); |
| 748 |
} |
| 749 |
} ) |
| 750 |
); |
| 751 |
|
| 752 |
// Stop observing as soon as the page scrolls since we only want initial-viewport elements. |
| 753 |
win.addEventListener( 'scroll', disconnectIntersectionObserver, { |
| 754 |
once: true, |
| 755 |
passive: true, |
| 756 |
} ); |
| 757 |
} |
| 758 |
|
| 759 |
/** @type {(LCPMetric|LCPMetricWithAttribution)[]} */ |
| 760 |
const lcpMetricCandidates = []; |
| 761 |
|
| 762 |
// Get at least one LCP candidate. More may be reported before the page finishes loading. |
| 763 |
await /** @type {Promise<void>} */ ( |
| 764 |
new Promise( ( resolve ) => { |
| 765 |
onLCP( |
| 766 |
/** |
| 767 |
* Handles an LCP metric being reported. |
| 768 |
* |
| 769 |
* @param {LCPMetric|LCPMetricWithAttribution} metric |
| 770 |
*/ |
| 771 |
( metric ) => { |
| 772 |
lcpMetricCandidates.push( metric ); |
| 773 |
resolve(); |
| 774 |
}, |
| 775 |
{ |
| 776 |
// This avoids needing to click to finalize the LCP candidate. While this is helpful for testing, it also |
| 777 |
// ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the |
| 778 |
// user never does a click or keydown, per <https://github.com/GoogleChrome/web-vitals/blob/07f6f96/src/onLCP.ts#L99-L107>. |
| 779 |
reportAllChanges: true, |
| 780 |
} |
| 781 |
); |
| 782 |
} ) |
| 783 |
); |
| 784 |
|
| 785 |
// Stop observing the initial viewport. |
| 786 |
disconnectIntersectionObserver(); |
| 787 |
|
| 788 |
urlMetric = { |
| 789 |
url: currentUrl, |
| 790 |
viewport: { |
| 791 |
width: win.innerWidth, |
| 792 |
height: win.innerHeight, |
| 793 |
}, |
| 794 |
elements: [], |
| 795 |
}; |
| 796 |
|
| 797 |
const lcpMetric = lcpMetricCandidates[ lcpMetricCandidates.length - 1 ]; |
| 798 |
|
| 799 |
// Populate the elements in the URL Metric. |
| 800 |
for ( const elementIntersection of elementIntersections ) { |
| 801 |
const xpath = breadcrumbedElementsMap.get( elementIntersection.target ); |
| 802 |
if ( ! xpath ) { |
| 803 |
warn( 'Unable to look up XPath for element' ); |
| 804 |
continue; |
| 805 |
} |
| 806 |
|
| 807 |
const element = /** @type {Element|null} */ ( |
| 808 |
lcpMetric?.entries[ 0 ]?.element |
| 809 |
); |
| 810 |
const isLCP = elementIntersection.target === element; |
| 811 |
|
| 812 |
/** @type {ElementData} */ |
| 813 |
const elementData = { |
| 814 |
isLCP, |
| 815 |
isLCPCandidate: !! lcpMetricCandidates.find( |
| 816 |
( lcpMetricCandidate ) => { |
| 817 |
const candidateElement = /** @type {Element|null} */ ( |
| 818 |
lcpMetricCandidate.entries[ 0 ]?.element |
| 819 |
); |
| 820 |
return candidateElement === elementIntersection.target; |
| 821 |
} |
| 822 |
), |
| 823 |
xpath, |
| 824 |
intersectionRatio: elementIntersection.intersectionRatio, |
| 825 |
intersectionRect: elementIntersection.intersectionRect, |
| 826 |
boundingClientRect: elementIntersection.boundingClientRect, |
| 827 |
}; |
| 828 |
|
| 829 |
urlMetric.elements.push( elementData ); |
| 830 |
elementsByXPath.set( elementData.xpath, elementData ); |
| 831 |
} |
| 832 |
breadcrumbedElementsMap.clear(); // No longer needed. |
| 833 |
|
| 834 |
/** |
| 835 |
* Initialize extensions. |
| 836 |
*/ |
| 837 |
|
| 838 |
/** @type {Map<string, Extension>} */ |
| 839 |
const extensions = new Map(); |
| 840 |
|
| 841 |
/** @type {boolean} */ |
| 842 |
let extensionHasFinalize = false; |
| 843 |
|
| 844 |
/** @type {Promise<void>[]} */ |
| 845 |
const extensionInitializePromises = []; |
| 846 |
|
| 847 |
/** @type {string[]} */ |
| 848 |
const initializingExtensionModuleUrls = []; |
| 849 |
|
| 850 |
// Load all extensions in parallel. |
| 851 |
await Promise.all( |
| 852 |
extensionModuleUrls.map( async ( extensionModuleUrl ) => { |
| 853 |
const extension = /** @type {Extension} */ await import( |
| 854 |
extensionModuleUrl |
| 855 |
); |
| 856 |
extensions.set( extensionModuleUrl, extension ); |
| 857 |
} ) |
| 858 |
); |
| 859 |
|
| 860 |
// Initialize extensions. |
| 861 |
for ( const [ extensionModuleUrl, extension ] of extensions.entries() ) { |
| 862 |
try { |
| 863 |
const extensionLogger = createLogger( |
| 864 |
isDebug, |
| 865 |
`[Optimization Detective: ${ |
| 866 |
extension.name || |
| 867 |
getExtensionNameFromScriptModuleUrl( extensionModuleUrl ) |
| 868 |
}]`, |
| 869 |
extensionModuleUrl |
| 870 |
); |
| 871 |
|
| 872 |
// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. |
| 873 |
if ( extension.initialize instanceof Function ) { |
| 874 |
const initializePromise = extension.initialize( { |
| 875 |
isDebug, |
| 876 |
...extensionLogger, |
| 877 |
onTTFB, |
| 878 |
onFCP, |
| 879 |
onLCP, |
| 880 |
onINP, |
| 881 |
onCLS, |
| 882 |
getRootData, |
| 883 |
extendRootData, |
| 884 |
getElementData, |
| 885 |
extendElementData, |
| 886 |
} ); |
| 887 |
if ( initializePromise instanceof Promise ) { |
| 888 |
extensionInitializePromises.push( initializePromise ); |
| 889 |
initializingExtensionModuleUrls.push( extensionModuleUrl ); |
| 890 |
} |
| 891 |
} |
| 892 |
|
| 893 |
if ( extension.finalize instanceof Function ) { |
| 894 |
extensionLogger.warn( |
| 895 |
'Use of the finalize function in extensions is deprecated. Please refactor your extension to use the initialize function instead, and update the URL Metric data as soon as a change is detected rather than waiting until finalization.' |
| 896 |
); |
| 897 |
extensionHasFinalize = true; |
| 898 |
} |
| 899 |
} catch ( err ) { |
| 900 |
error( |
| 901 |
`Failed to start initializing extension '${ extensionModuleUrl }':`, |
| 902 |
err |
| 903 |
); |
| 904 |
} |
| 905 |
} |
| 906 |
|
| 907 |
// Wait for all extensions to finish initializing. |
| 908 |
const settledInitializePromises = await Promise.allSettled( |
| 909 |
extensionInitializePromises |
| 910 |
); |
| 911 |
for ( const [ |
| 912 |
i, |
| 913 |
settledInitializePromise, |
| 914 |
] of settledInitializePromises.entries() ) { |
| 915 |
if ( settledInitializePromise.status === 'rejected' ) { |
| 916 |
error( |
| 917 |
`Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`, |
| 918 |
settledInitializePromise.reason |
| 919 |
); |
| 920 |
} |
| 921 |
} |
| 922 |
|
| 923 |
if ( compressionEnabled && extensionHasFinalize ) { |
| 924 |
compressionEnabled = false; |
| 925 |
warn( |
| 926 |
'URL Metric compression is disabled because one or more extensions use the deprecated finalize function.' |
| 927 |
); |
| 928 |
} |
| 929 |
|
| 930 |
log( 'Current URL Metric:', urlMetric ); |
| 931 |
|
| 932 |
// Compress the URL Metric once so that even if there are no extensions available or extending the URL Metric, it is compressed. |
| 933 |
debounceCompressUrlMetric(); |
| 934 |
|
| 935 |
// Wait for the page to be hidden. |
| 936 |
await /** @type {Promise<void>} */ ( |
| 937 |
new Promise( ( resolve ) => { |
| 938 |
win.addEventListener( 'pagehide', () => resolve(), { once: true } ); |
| 939 |
win.addEventListener( 'pageswap', () => resolve(), { once: true } ); |
| 940 |
doc.addEventListener( |
| 941 |
'visibilitychange', |
| 942 |
() => { |
| 943 |
if ( doc.visibilityState === 'hidden' ) { |
| 944 |
// TODO: This will fire even when switching tabs. |
| 945 |
resolve(); |
| 946 |
} |
| 947 |
}, |
| 948 |
{ once: true } |
| 949 |
); |
| 950 |
} ) |
| 951 |
); |
| 952 |
|
| 953 |
// Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due |
| 954 |
// to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected. |
| 955 |
if ( didWindowResize ) { |
| 956 |
log( 'Aborting URL Metric collection due to viewport size change.' ); |
| 957 |
return; |
| 958 |
} |
| 959 |
|
| 960 |
// Finalize extensions. |
| 961 |
if ( extensions.size > 0 ) { |
| 962 |
/** @type {Promise<void>[]} */ |
| 963 |
const extensionFinalizePromises = []; |
| 964 |
|
| 965 |
/** @type {string[]} */ |
| 966 |
const finalizingExtensionModuleUrls = []; |
| 967 |
|
| 968 |
for ( const [ |
| 969 |
extensionModuleUrl, |
| 970 |
extension, |
| 971 |
] of extensions.entries() ) { |
| 972 |
if ( extension.finalize instanceof Function ) { |
| 973 |
const extensionLogger = createLogger( |
| 974 |
isDebug, |
| 975 |
`[Optimization Detective: ${ |
| 976 |
extension.name || |
| 977 |
getExtensionNameFromScriptModuleUrl( |
| 978 |
extensionModuleUrl |
| 979 |
) |
| 980 |
}]`, |
| 981 |
extensionModuleUrl |
| 982 |
); |
| 983 |
|
| 984 |
try { |
| 985 |
const finalizePromise = extension.finalize( { |
| 986 |
isDebug, |
| 987 |
...extensionLogger, |
| 988 |
getRootData, |
| 989 |
getElementData, |
| 990 |
extendElementData, |
| 991 |
extendRootData, |
| 992 |
} ); |
| 993 |
if ( finalizePromise instanceof Promise ) { |
| 994 |
extensionFinalizePromises.push( finalizePromise ); |
| 995 |
finalizingExtensionModuleUrls.push( |
| 996 |
extensionModuleUrl |
| 997 |
); |
| 998 |
} |
| 999 |
} catch ( err ) { |
| 1000 |
error( |
| 1001 |
`Unable to start finalizing extension '${ extensionModuleUrl }':`, |
| 1002 |
err |
| 1003 |
); |
| 1004 |
} |
| 1005 |
} |
| 1006 |
} |
| 1007 |
|
| 1008 |
// Wait for all extensions to finish finalizing. |
| 1009 |
const settledFinalizePromises = await Promise.allSettled( |
| 1010 |
extensionFinalizePromises |
| 1011 |
); |
| 1012 |
for ( const [ |
| 1013 |
i, |
| 1014 |
settledFinalizePromise, |
| 1015 |
] of settledFinalizePromises.entries() ) { |
| 1016 |
if ( settledFinalizePromise.status === 'rejected' ) { |
| 1017 |
error( |
| 1018 |
`Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`, |
| 1019 |
settledFinalizePromise.reason |
| 1020 |
); |
| 1021 |
} |
| 1022 |
} |
| 1023 |
} |
| 1024 |
|
| 1025 |
/* |
| 1026 |
* Now prepare the URL Metric to be sent in the JSON request body. |
| 1027 |
*/ |
| 1028 |
|
| 1029 |
const maxBodyLengthKiB = 64; |
| 1030 |
const maxBodyLengthBytes = maxBodyLengthKiB * 1024; |
| 1031 |
|
| 1032 |
const jsonBody = JSON.stringify( urlMetric ); |
| 1033 |
if ( jsonBody.length > maxUrlMetricSize ) { |
| 1034 |
error( |
| 1035 |
`URL Metric is ${ jsonBody.length.toLocaleString() } bytes, exceeding the maximum size of ${ maxUrlMetricSize.toLocaleString() } bytes:`, |
| 1036 |
urlMetric |
| 1037 |
); |
| 1038 |
return; |
| 1039 |
} |
| 1040 |
compressionEnabled = compressionEnabled && null !== compressedPayload; |
| 1041 |
const payloadBlob = |
| 1042 |
compressionEnabled && compressedPayload |
| 1043 |
? compressedPayload |
| 1044 |
: new Blob( [ jsonBody ], { type: 'application/json' } ); |
| 1045 |
const percentOfBudget = |
| 1046 |
( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100; |
| 1047 |
|
| 1048 |
/* |
| 1049 |
* According to the fetch() spec: |
| 1050 |
* "If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error." |
| 1051 |
* This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater |
| 1052 |
* than the maximum, we should avoid even trying to send it. |
| 1053 |
*/ |
| 1054 |
if ( payloadBlob.size > maxBodyLengthBytes ) { |
| 1055 |
error( |
| 1056 |
`Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round( |
| 1057 |
percentOfBudget |
| 1058 |
) }% of ${ maxBodyLengthKiB } KiB limit:`, |
| 1059 |
urlMetric |
| 1060 |
); |
| 1061 |
return; |
| 1062 |
} |
| 1063 |
|
| 1064 |
// Even though the server may reject the REST API request, we still have to set the storage lock |
| 1065 |
// because we can't look at the response when sending a beacon. |
| 1066 |
setStorageLock( getCurrentTime() ); |
| 1067 |
|
| 1068 |
// Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client. |
| 1069 |
if ( null !== alreadySubmittedSessionStorageKey ) { |
| 1070 |
sessionStorage.setItem( |
| 1071 |
alreadySubmittedSessionStorageKey, |
| 1072 |
String( getCurrentTime() ) |
| 1073 |
); |
| 1074 |
} |
| 1075 |
|
| 1076 |
let message = 'Sending URL Metric ('; |
| 1077 |
message += `${ payloadBlob.size.toLocaleString() } bytes`; |
| 1078 |
message += `, ${ Math.round( |
| 1079 |
percentOfBudget |
| 1080 |
) }% of ${ maxBodyLengthKiB } KiB limit`; |
| 1081 |
if ( compressionEnabled ) { |
| 1082 |
message += `, gzip compressed -${ Math.round( |
| 1083 |
( ( jsonBody.length - payloadBlob.size ) / jsonBody.length ) * 100 |
| 1084 |
) }%`; |
| 1085 |
} else { |
| 1086 |
message += ', uncompressed'; |
| 1087 |
} |
| 1088 |
message += '):'; |
| 1089 |
|
| 1090 |
// The threshold of 50% is used because the limit for all beacons combined is 64 KiB, not just the data for one beacon. |
| 1091 |
if ( percentOfBudget < 50 ) { |
| 1092 |
log( message, urlMetric ); |
| 1093 |
} else { |
| 1094 |
warn( message, urlMetric ); |
| 1095 |
} |
| 1096 |
|
| 1097 |
const url = new URL( restApiEndpoint ); |
| 1098 |
if ( typeof restApiNonce === 'string' ) { |
| 1099 |
url.searchParams.set( '_wpnonce', restApiNonce ); |
| 1100 |
} |
| 1101 |
url.searchParams.set( 'slug', urlMetricSlug ); |
| 1102 |
url.searchParams.set( 'current_etag', currentETag ); |
| 1103 |
if ( typeof cachePurgePostId === 'number' ) { |
| 1104 |
url.searchParams.set( |
| 1105 |
'cache_purge_post_id', |
| 1106 |
cachePurgePostId.toString() |
| 1107 |
); |
| 1108 |
} |
| 1109 |
url.searchParams.set( 'hmac', urlMetricHMAC ); |
| 1110 |
|
| 1111 |
/** @type {Record<string, string>} */ |
| 1112 |
const headers = { |
| 1113 |
'Content-Type': 'application/json', |
| 1114 |
}; |
| 1115 |
if ( compressionEnabled ) { |
| 1116 |
headers[ 'Content-Encoding' ] = 'gzip'; |
| 1117 |
} |
| 1118 |
|
| 1119 |
const request = new Request( url, { |
| 1120 |
method: 'POST', |
| 1121 |
body: payloadBlob, |
| 1122 |
headers, |
| 1123 |
keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon(). |
| 1124 |
} ); |
| 1125 |
await fetch( request ); |
| 1126 |
} |
| 1127 |
|