PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
← All changes | includes/class-preloader.php +343 -15 1.0.21.3.2 View file →
@@ -39,8 +39,68 @@
39 39 public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
40 40 public const REQUEST_TIMEOUT = 8;
41 41
42 42 /**
43 + * Default cap on NEW remote images resolved per warmed page.
44 + *
45 + * A crawl warms the cache; it is not a licence to hit third-party hosts
46 + * hundreds of times for one page.
47 + *
48 + * The cap counts only images whose dimensions are not already known, and
49 + * results persist between runs — so each crawl advances through a heavily
50 + * embedded page rather than re-picking the same first N. That is what
51 + * makes a cap safe here: without the skip it would strand everything past
52 + * the limit permanently, because images appear in the same DOM order
53 + * every time.
54 + *
55 + * 20 is a starting point, not a measurement. Sites that embed more can
56 + * raise it via `xspeed_preloader_remote_dimension_limit`.
57 + */
58 + private const REMOTE_DIMENSION_LIMIT = 20;
59 +
60 + /**
61 + * How many new remote images one warmed page may resolve.
62 + */
63 + private static function remote_dimension_limit(): int {
64 + /**
65 + * Filter the per-page cap on remote dimension lookups.
66 + *
67 + * @param int $limit Default 20. Values below 1 disable the lookup.
68 + */
69 + return (int) apply_filters( 'xspeed_preloader_remote_dimension_limit', self::REMOTE_DIMENSION_LIMIT );
70 + }
71 +
72 + /**
73 + * Why the top-level sitemap fetch failed on this request, or '' when it
74 + * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the
75 + * reason has to survive the return of an empty array, which is exactly
76 + * what it could not do before. Request-scoped; never persisted. (#142)
77 + *
78 + * @var string
79 + */
80 + private static $last_sitemap_error = '';
81 +
82 + /**
83 + * The sitemap URL the last error refers to. Kept beside the message so
84 + * an error entry can carry a real `url` field like every other one,
85 + * rather than repeating the URL already inside the message text.
86 + */
87 + private static $last_sitemap_url = '';
88 +
89 + /**
90 + * How the queue for the current crawl was built — 'sitemap', 'fallback'
91 + * (enumerated from the database because the sitemap was unreachable), or
92 + * 'none'. Surfaced in the state so the panel, REST and CLI can each say
93 + * what actually happened instead of reporting a bare zero. (#142)
94 + *
95 + * @var string
96 + */
97 + private static $queue_source = 'none';
98 +
99 + /** Largest number of URLs the database fallback will enumerate. */
100 + private const FALLBACK_LIMIT = 500;
101 +
102 + /**
43 103 * Kick off a fresh crawl. Returns the initial state.
44 104 */
45 105 public static function start(): array {
46 106 $opts = Settings_Manager::get( 'preloader' );
@@ -45,25 +105,72 @@
45 105 public static function start(): array {
46 106 $opts = Settings_Manager::get( 'preloader' );
47 107 $urls = self::resolve_queue( $opts );
48 108
109 + // A crawl that queued nothing because the sitemap was unreachable is a
110 + // FAILURE, and every layer above needs to be able to say so. It used
111 + // to be indistinguishable from success: errors stayed empty, the REST
112 + // route returned 200, and the CLI printed a green Success. (#142)
113 + $sitemap_error = self::$last_sitemap_error;
114 + $errors = array();
115 + if ( '' !== $sitemap_error && empty( $urls ) ) {
116 + // Same {url, error, ts} shape every other entry uses. A bare
117 + // string here fataled `wp xspeed preloader status`, which
118 + // destructures `$e['url']` over the list — and took the MCP
119 + // `get_preloader_status` tool down with it, so an agent asking
120 + // why the preload failed got "Cannot access offset of type
121 + // string on string" instead of the reason this code records.
122 + // `url` is the sitemap because that is what failed. (QA F1)
123 + $errors[] = array(
124 + 'url' => self::$last_sitemap_url,
125 + 'error' => $sitemap_error,
126 + 'ts' => time(),
127 + );
128 + }
129 +
49 130 $state = array(
50 - 'running' => ! empty( $urls ),
51 - 'started_at' => time(),
52 - 'finished_at' => 0,
53 - 'queue' => array_values( $urls ),
54 - 'processed' => 0,
55 - 'total' => count( $urls ),
56 - 'last_url' => '',
57 - 'errors' => array(),
131 + 'running' => ! empty( $urls ),
132 + 'started_at' => time(),
133 + 'finished_at' => empty( $urls ) ? time() : 0,
134 + 'queue' => array_values( $urls ),
135 + 'processed' => 0,
136 + 'total' => count( $urls ),
137 + 'last_url' => '',
138 + 'errors' => $errors,
139 + // Consumers render on these: the panel needs to distinguish
140 + // "not started" from "ran and found nothing", and to tell the
141 + // user when the queue came from the fallback rather than the
142 + // sitemap they configured.
143 + 'source' => self::$queue_source,
144 + 'sitemap_error' => $sitemap_error,
58 145 );
59 146 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
60 147
61 - Activity_Log::record(
62 - 'preloader_started',
63 - sprintf( 'Preloader queued %d URL%s for warming.', $state['total'], 1 === $state['total'] ? '' : 's' ),
64 - $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN
65 - );
148 + if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
149 + $message = sprintf(
150 + /* translators: 1: number of URLs, 2: the sitemap failure detail. */
151 + __( 'Preloader queued %1$d URLs from the site content — %2$s', 'xspeed' ),
152 + $state['total'],
153 + $sitemap_error
154 + );
155 + $severity = Activity_Log::WARN;
156 + } elseif ( '' !== $sitemap_error ) {
157 + $message = sprintf(
158 + /* translators: %s: the sitemap failure detail. */
159 + __( 'Preloader could not start — %s', 'xspeed' ),
160 + $sitemap_error
161 + );
162 + $severity = Activity_Log::WARN;
163 + } else {
164 + $message = sprintf(
165 + /* translators: 1: number of URLs, 2: plural suffix. */
166 + __( 'Preloader queued %1$d URL%2$s for warming.', 'xspeed' ),
167 + $state['total'],
168 + 1 === $state['total'] ? '' : 's'
169 + );
170 + $severity = $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN;
171 + }
172 + Activity_Log::record( 'preloader_started', $message, $severity );
66 173
67 174 // Schedule the first tick ~5 seconds out so the kick-off REST call
68 175 // returns instantly; wp_schedule_single_event covers the
69 176 // "process the queue ASAP" path without a heavy synchronous loop.
@@ -234,11 +341,102 @@
234 341 'error' => sprintf( 'HTTP %d', $code ),
235 342 'ts' => time(),
236 343 );
237 344 $state['errors'] = array_slice( $state['errors'], -20 );
345 + return;
238 346 }
347 +
348 + self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
239 349 }
240 350
351 + /**
352 + * Resolve dimensions for externally hosted images found on a warmed page.
353 + *
354 + * The crawl already has the HTML in hand, so harvesting image URLs from it
355 + * costs nothing extra — and this is the one place where paying for a
356 + * remote lookup is free of consequence, because no visitor is waiting.
357 + *
358 + * An image on another domain has no local file to measure, so the front
359 + * end skips it and the page ships without width/height — which is layout
360 + * shift, on precisely the sites least able to fix it by hand (a CDN, a
361 + * sister site, a shared asset host). Warming here means the NEXT render
362 + * finds the dimensions in cache and stamps them, with the visitor paying
363 + * nothing.
364 + *
365 + * Deliberately bounded per page: a crawl should not turn into a scraper
366 + * for a page embedding hundreds of third-party images.
367 + *
368 + * @param string $html The warmed page's HTML.
369 + */
370 + private static function warm_remote_dimensions( string $html ): void {
371 + if ( '' === $html || ! class_exists( '\XSpeed\Lazy_Loader' ) ) {
372 + return;
373 + }
374 +
375 + $opts = Settings_Manager::get( 'lazy' );
376 + if ( empty( $opts['add_missing_dimensions'] ) ) {
377 + return;
378 + }
379 +
380 + // Match any <img>, not only one carrying `src`. The URL worth warming
381 + // may live in a lazy attribute instead — which is the whole point of
382 + // #328 — and resolvable_image_url() below is what knows where to look.
383 + if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
384 + return;
385 + }
386 +
387 + $home = wp_parse_url( home_url(), PHP_URL_HOST );
388 + $targets = array();
389 + foreach ( $m as $tag ) {
390 + // Only tags MISSING a dimension are worth resolving — one that
391 + // already declares both needs nothing.
392 + // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare
393 + // `\bwidth=` also matches `data-width=`, so a slider carrying its
394 + // own metadata looked already-sized and was skipped from warming.
395 + // The two must agree, or the collector skips exactly the tags the
396 + // renderer still needs measured. (#333 review round 3, issue 2)
397 + if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) {
398 + continue;
399 + }
400 + // Ask the same resolver the render path uses, rather than reading
401 + // `src` directly. A slider parks a spacer in `src` and the real
402 + // URL in `data-lazy`/`data-src`/`data-original`, so a collector
403 + // looking only at `src` warmed the SPACER and never the image —
404 + // leaving remotely-hosted slider images unresolvable at render
405 + // time, the exact markup #328 is about. (#333 review round 2,
406 + // issue 3)
407 + // `false`: do not let the resolver settle a name-refused URL by
408 + // MEASURING it. That is circular here — remote measurement is
409 + // gated until warm_dimensions() sets $warming, and this collector
410 + // is what feeds warm_dimensions(). Take the URL the tag offers and
411 + // let the warm pass decide. (#333 review round 3, issue 3)
412 + $src = Lazy_Loader::resolvable_image_url( $tag[0], false );
413 + if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) {
414 + continue;
415 + }
416 + $host = wp_parse_url( $src, PHP_URL_HOST );
417 + if ( ! $host || $host === $home ) {
418 + continue; // Local images already resolve from disk.
419 + }
420 + // Already resolved (or already known unresolvable) — looking it up
421 + // again costs a request and teaches us nothing. Skipping it is
422 + // also what makes the cap below advance: images appear in the same
423 + // DOM order every crawl, so a collector that did not skip would
424 + // re-pick the same first N for ever and never reach the rest.
425 + if ( Lazy_Loader::dimensions_known( $src ) ) {
426 + continue;
427 + }
428 + $targets[ $src ] = true;
429 + if ( count( $targets ) >= self::remote_dimension_limit() ) {
430 + break;
431 + }
432 + }
433 +
434 + if ( $targets ) {
435 + Lazy_Loader::warm_dimensions( array_keys( $targets ) );
436 + }
437 + }
438 +
241 439 private static function mark_complete( array $state ): void {
242 440 $state['running'] = false;
243 441 $state['finished_at'] = time();
244 442 $state['queue'] = array();
@@ -263,8 +461,14 @@
263 461 *
264 462 * @return string[]
265 463 */
266 464 private static function resolve_queue( array $opts ): array {
465 + // Request-scoped statics: reset so a previous crawl in the same
466 + // process can't leak its verdict into this one.
467 + self::$last_sitemap_error = '';
468 + self::$last_sitemap_url = '';
469 + self::$queue_source = 'none';
470 +
267 471 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
268 472 if ( '' === $sitemap ) {
269 473 $sitemap = home_url( '/wp-sitemap.xml' );
270 474 }
@@ -269,9 +473,28 @@
269 473 $sitemap = home_url( '/wp-sitemap.xml' );
270 474 }
271 475
272 476 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
477 + $from = empty( $urls ) ? 'none' : 'sitemap';
273 478
479 + /*
480 + * A missing sitemap must not disable the feature. Two very common
481 + * setups produce one with no misconfiguration by the user:
482 + * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
483 + * standard on staging and pre-launch sites), and an SEO plugin
484 + * filtering `wp_sitemaps_enabled` to false while serving its own
485 + * sitemap at a path we were never told about.
486 + *
487 + * Enumerate warmable URLs straight from the database instead. Only
488 + * on a genuine fetch FAILURE — a sitemap that is reachable and
489 + * legitimately empty is a real answer, and silently crawling
490 + * something else would be worse than doing nothing. (#142)
491 + */
492 + if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
493 + $urls = self::fallback_urls();
494 + $from = empty( $urls ) ? 'none' : 'fallback';
495 + }
496 +
274 497 $cache_opts = Settings_Manager::get( 'cache' );
275 498 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
276 499 if ( ! empty( $excluded ) ) {
277 500 $urls = array_filter(
@@ -289,12 +512,90 @@
289 512 }
290 513
291 514 // Dedup + cap at 5000 to bound the transient size on huge sites.
292 515 $urls = array_values( array_unique( $urls ) );
293 - return array_slice( $urls, 0, 5000 );
516 + $urls = array_slice( $urls, 0, 5000 );
517 +
518 + /*
519 + * Commit the verdict only now, AFTER the exclusion filter — the
520 + * source describes what we ACTUALLY queued, not what we hoped to.
521 + * Setting it earlier let a queue that the exclusions stripped to
522 + * nothing still claim `fallback`, so the panel announced "Warmed
523 + * from site content" over 0 URLs, and `crawlFailed` (which needs
524 + * source !== 'fallback' at total 0) could never become true.
525 + * One assignment fixes both. (QA F2/F3 on #155)
526 + */
527 + self::$queue_source = empty( $urls ) ? 'none' : $from;
528 +
529 + return $urls;
294 530 }
295 531
296 532 /**
533 + * Enumerate warmable URLs from the database, for sites whose sitemap
534 + * can't be fetched. Deliberately modest in scope: the home page, then
535 + * the most recently modified public posts across every public post type.
536 + * Newest-first is the right bias — those are the URLs most likely to be
537 + * requested and least likely to be warm already.
538 + *
539 + * Uses WP_Query rather than SQL so post-type registration, status
540 + * handling and multisite switching all behave the way the rest of
541 + * WordPress does.
542 + *
543 + * @return string[]
544 + */
545 + private static function fallback_urls(): array {
546 + $urls = array();
547 + $home = (string) home_url( '/' );
548 + if ( '' !== trim( $home, '/' ) ) {
549 + $urls[] = $home;
550 + }
551 +
552 + $types = get_post_types(
553 + array(
554 + 'public' => true,
555 + 'publicly_queryable' => true,
556 + )
557 + );
558 + // `page` is public but not publicly_queryable, so the query above
559 + // misses it — and pages are exactly what a warm cache wants most.
560 + // Only add it when the site has post types at all: an empty list
561 + // means there is nothing to enumerate, and constructing a WP_Query
562 + // for it would be wasted work.
563 + if ( ! empty( $types ) ) {
564 + $types['page'] = 'page';
565 + unset( $types['attachment'] );
566 + }
567 +
568 + if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
569 + return $urls;
570 + }
571 +
572 + $query = new \WP_Query(
573 + array(
574 + 'post_type' => array_values( $types ),
575 + 'post_status' => 'publish',
576 + 'posts_per_page' => self::FALLBACK_LIMIT,
577 + 'orderby' => 'modified',
578 + 'order' => 'DESC',
579 + 'ignore_sticky_posts' => true,
580 + 'no_found_rows' => true,
581 + 'update_post_meta_cache' => false,
582 + 'update_post_term_cache' => false,
583 + 'fields' => 'ids',
584 + )
585 + );
586 +
587 + foreach ( $query->posts as $post_id ) {
588 + $permalink = get_permalink( (int) $post_id );
589 + if ( is_string( $permalink ) && '' !== $permalink ) {
590 + $urls[] = $permalink;
591 + }
592 + }
593 +
594 + return array_values( array_unique( $urls ) );
595 + }
596 +
597 + /**
297 598 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
298 599 * deep index can't stack-overflow.
299 600 */
300 601 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
@@ -308,9 +609,36 @@
308 609 'sslverify' => false,
309 610 'user-agent' => self::USER_AGENT,
310 611 )
311 612 );
312 - if ( is_wp_error( $res ) || (int) wp_remote_retrieve_response_code( $res ) >= 400 ) {
613 + if ( is_wp_error( $res ) ) {
614 + // Record WHY, don't just vanish. "Unreachable" and "valid but
615 + // empty" both used to collapse into an empty array here, which is
616 + // what made a sitemap-less site look like a successful crawl of
617 + // zero URLs. Only the top-level fetch is recorded: a nested index
618 + // failing is a partial result, not a dead crawl. (#142)
619 + if ( 0 === $depth ) {
620 + self::$last_sitemap_error = sprintf(
621 + /* translators: 1: sitemap URL, 2: error detail. */
622 + __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
623 + $sitemap_url,
624 + $res->get_error_message()
625 + );
626 + self::$last_sitemap_url = (string) $sitemap_url;
627 + }
628 + return array();
629 + }
630 + $code = (int) wp_remote_retrieve_response_code( $res );
631 + if ( $code >= 400 ) {
632 + if ( 0 === $depth ) {
633 + self::$last_sitemap_error = sprintf(
634 + /* translators: 1: sitemap URL, 2: HTTP status code. */
635 + __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
636 + $sitemap_url,
637 + $code
638 + );
639 + self::$last_sitemap_url = (string) $sitemap_url;
640 + }
313 641 return array();
314 642 }
315 643 $body = (string) wp_remote_retrieve_body( $res );
316 644 if ( '' === $body ) {