PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 35 releases
← All changes | includes/class-preloader.php +277 -54 1.3.1 → 1.4.0 View file →
@@ -35,9 +35,24 @@
35 35
36 36 public const STATE_KEY = 'xspeed_preloader_state';
37 37 public const STATE_TTL = 86400; // 24h — long enough for slow crawls.
38 38 public const CRON_HOOK = 'xspeed_preloader_tick';
39 - public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
39 + /**
40 + * User-agent for every request the preloader makes.
41 + *
42 + * Deliberately contains no substring from the 7G/8G bad-bot lists. The
43 + * previous value, "xSpeed-Preloader/1.0", matched the `loader` token in
44 + * the alphabetical slice `(linkscan|linkwalker|loader|lwp-download|...)`
45 + * — a match on "Pre*loader*" — so nginx ports of 8G answered every warm
46 + * with 403 and newly published posts were never warmed. Upstream 8G
47 + * v1.5 has since dropped `loader`, but forks and vendored copies (xCloud
48 + * among them) still ship the older slice, so the name has to stay clear
49 + * of it. "Warmer" matches nothing in either list. (#481)
50 + *
51 + * Read through user_agent() rather than using this constant directly, so
52 + * the `xspeed_preloader_user_agent` filter applies.
53 + */
54 + public const USER_AGENT = 'xSpeed-Warmer/1.0 (+cache warmer; admin-initiated)';
40 55 public const REQUEST_TIMEOUT = 8;
41 56
42 57 /**
43 58 * Default cap on NEW remote images resolved per warmed page.
@@ -57,8 +72,220 @@
57 72 */
58 73 private const REMOTE_DIMENSION_LIMIT = 20;
59 74
60 75 /**
76 + * The user-agent every preloader request sends.
77 + *
78 + * Filterable because the blocking rule lives on the server, not here: a
79 + * host with its own bad-bot list can clear a warm without patching the
80 + * plugin or waiting for a release. An empty filter return is ignored —
81 + * sending no UA gets a request blocked at least as often. (#481)
82 + */
83 + public static function user_agent(): string {
84 + /**
85 + * Filter the preloader's user-agent string.
86 + *
87 + * @param string $user_agent Default self::USER_AGENT.
88 + */
89 + $ua = apply_filters( 'xspeed_preloader_user_agent', self::USER_AGENT );
90 +
91 + return ( is_string( $ua ) && '' !== trim( $ua ) ) ? trim( $ua ) : self::USER_AGENT;
92 + }
93 +
94 + /**
95 + * A real phone browser's user-agent, which the warmer's own is appended
96 + * to for the phone copy.
97 + *
98 + * A real phone string rather than the warmer's UA plus a client hint:
99 + * a theme or plugin that reads the user-agent itself (Mobile_Detect and
100 + * the like) instead of calling wp_is_mobile() would otherwise render its
101 + * desktop HTML into the phone copy. The warmer's name stays at the end so
102 + * the request is still recognisable in access logs and still matches no
103 + * bad-bot rule. (#596)
104 + */
105 + public const MOBILE_UA_PREFIX = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
106 +
107 + /** The user-agent the phone warm sends. */
108 + public static function mobile_user_agent(): string {
109 + $default = self::MOBILE_UA_PREFIX . ' ' . self::user_agent();
110 + /**
111 + * Filter the user-agent the preloader sends when it warms the phone
112 + * copy of a page (Separate Mobile Cache on).
113 + *
114 + * It must still read as a phone: the cache files the response by the
115 + * same test as wp_is_mobile(). An empty return is ignored.
116 + *
117 + * @param string $user_agent Default: an iPhone Safari string followed by the warmer's user-agent.
118 + */
119 + $ua = apply_filters( 'xspeed_preloader_mobile_user_agent', $default );
120 +
121 + return ( is_string( $ua ) && '' !== trim( $ua ) ) ? trim( $ua ) : $default;
122 + }
123 +
124 + /**
125 + * The copies of a page a warm fills: the desktop one, and the phone one
126 + * too when Separate Mobile Cache keeps one per device. Before #596 every
127 + * warm sent a desktop user-agent, so the first phone visitor to each
128 + * page got an uncached render.
129 + *
130 + * @return string[] 'desktop', then 'mobile' when it applies.
131 + */
132 + public static function devices(): array {
133 + $cache = Settings_Manager::get( 'cache' );
134 + $devices = empty( $cache['mobile_separate'] ) ? array( 'desktop' ) : array( 'desktop', 'mobile' );
135 + /**
136 + * Filter which copies of a page the preloader warms.
137 + *
138 + * Return array( 'desktop' ) to skip the phone copy, for example on a
139 + * host where the extra requests cost too much. Unknown values are
140 + * dropped; an empty list means desktop.
141 + *
142 + * @param string[] $devices 'desktop', plus 'mobile' when Separate Mobile Cache is on.
143 + */
144 + $filtered = apply_filters( 'xspeed_preloader_devices', $devices );
145 + $filtered = is_array( $filtered ) ? array_values( array_intersect( array( 'desktop', 'mobile' ), $filtered ) ) : $devices;
146 +
147 + return empty( $filtered ) ? array( 'desktop' ) : $filtered;
148 + }
149 +
150 + /**
151 + * Request arguments for one warm of one device copy.
152 + *
153 + * `Sec-CH-UA-Mobile` settles the device whatever the user-agent filters
154 + * return: the cache checks that hint before the user-agent.
155 + *
156 + * @param array<string,string> $headers Extra request headers.
157 + */
158 + private static function warm_args( string $device, array $headers = array() ): array {
159 + $headers['Sec-CH-UA-Mobile'] = 'mobile' === $device ? '?1' : '?0';
160 + return array(
161 + 'timeout' => self::REQUEST_TIMEOUT,
162 + 'sslverify' => false,
163 + 'user-agent' => 'mobile' === $device ? self::mobile_user_agent() : self::user_agent(),
164 + 'headers' => Self_Traffic::headers( $headers ),
165 + 'blocking' => true,
166 + );
167 + }
168 +
169 + /**
170 + * Warm every copy of $url that devices() names.
171 + *
172 + * @param array<string,string> $headers Extra request headers.
173 + * @return array{failures: array<int,array{device:string,error:string}>, body: string}
174 + * The failures, and the desktop response body ('' when it failed).
175 + */
176 + private static function warm_devices( string $url, array $headers = array() ): array {
177 + $failures = array();
178 + $body = '';
179 + foreach ( self::devices() as $device ) {
180 + $args = self::warm_args( $device, $headers );
181 + $response = wp_remote_get( $url, $args );
182 + if ( is_wp_error( $response ) ) {
183 + $failures[] = array( 'device' => $device, 'error' => $response->get_error_message() );
184 + continue;
185 + }
186 + $code = (int) wp_remote_retrieve_response_code( $response );
187 + if ( $code >= 400 ) {
188 + $failures[] = array( 'device' => $device, 'error' => self::failure_detail( $code, $args['user-agent'] ) );
189 + if ( self::is_firewall_block( $code ) ) {
190 + self::remember_firewall_block( $url, $code, $args['user-agent'] );
191 + }
192 + continue;
193 + }
194 + if ( 'desktop' === $device ) {
195 + $body = (string) wp_remote_retrieve_body( $response );
196 + }
197 + }
198 + // The notice goes only once every copy got through. A host that
199 + // blocks just the phone user-agent would otherwise have the notice
200 + // cleared by each desktop warm right after the phone warm set it.
201 + if ( empty( $failures ) ) {
202 + self::clear_firewall_block();
203 + }
204 + return array( 'failures' => $failures, 'body' => $body );
205 + }
206 +
207 + /** "phone: " for a failure of the phone copy, so the log says which copy failed. */
208 + private static function device_prefix( string $device ): string {
209 + return 'mobile' === $device ? 'phone: ' : '';
210 + }
211 +
212 + /**
213 + * Is this status code the signature of a firewall refusing our warmer?
214 + *
215 + * 403 and 406 are what bad-bot rules (7G/8G, mod_security, Wordfence)
216 + * answer with. We only ever warm our OWN origin, and a page a visitor can
217 + * load must be loadable by us too — so these codes mean the request was
218 + * judged by its user-agent, not that the page is missing or broken. (#481)
219 + */
220 + private static function is_firewall_block( int $code ): bool {
221 + return in_array( $code, array( 403, 406 ), true );
222 + }
223 +
224 + /**
225 + * Explain a warm failure in terms the admin can act on.
226 + *
227 + * A bare "HTTP 403" sent people hunting a broken page; the page is fine,
228 + * and the fix is a server rule, so the message has to name the cause and
229 + * the exact UA to allow. (#481)
230 + */
231 + private static function failure_detail( int $code, string $user_agent = '' ): string {
232 + if ( ! self::is_firewall_block( $code ) ) {
233 + return sprintf( 'HTTP %d', $code );
234 + }
235 +
236 + return sprintf(
237 + 'HTTP %d — your server\'s firewall is blocking the xSpeed cache warmer by user-agent, so this page was not warmed. Allow the user-agent "%s" (on xCloud this is the 8G firewall\'s bad-bot rule), or change it with the xspeed_preloader_user_agent filter.',
238 + $code,
239 + '' !== $user_agent ? $user_agent : self::user_agent()
240 + );
241 + }
242 +
243 + /** Option holding the last firewall-shaped warm refusal. */
244 + public const FIREWALL_BLOCK_OPTION = 'xspeed_preloader_firewall_block';
245 +
246 + /**
247 + * Record that the origin refused a warm by user-agent, for ui_notices().
248 + *
249 + * An option rather than a transient: the condition is a server rule that
250 + * persists until someone changes it, and a notice that expired on its own
251 + * would let a site go back to never warming, silently. Cleared by
252 + * clear_firewall_block() on the first warm that succeeds. (#481)
253 + */
254 + private static function remember_firewall_block( string $url, int $code, string $user_agent = '' ): void {
255 + if ( ! function_exists( 'update_option' ) ) {
256 + return;
257 + }
258 + update_option(
259 + self::FIREWALL_BLOCK_OPTION,
260 + array(
261 + 'url' => $url,
262 + 'code' => $code,
263 + 'user_agent' => '' !== $user_agent ? $user_agent : self::user_agent(),
264 + 'ts' => time(),
265 + ),
266 + false
267 + );
268 + }
269 +
270 + /** Forget the firewall block once a warm gets through. */
271 + public static function clear_firewall_block(): void {
272 + if ( function_exists( 'delete_option' ) && self::firewall_block() ) {
273 + delete_option( self::FIREWALL_BLOCK_OPTION );
274 + }
275 + }
276 +
277 + /** The last firewall-shaped refusal, or null when there isn't one. */
278 + public static function firewall_block(): ?array {
279 + if ( ! function_exists( 'get_option' ) ) {
280 + return null;
281 + }
282 + $block = get_option( self::FIREWALL_BLOCK_OPTION, null );
283 +
284 + return ( is_array( $block ) && ! empty( $block['code'] ) ) ? $block : null;
285 + }
286 +
287 + /**
61 288 * How many new remote images one warmed page may resolve.
62 289 */
63 290 private static function remote_dimension_limit(): int {
64 291 /**
@@ -141,8 +368,13 @@
141 368 // user when the queue came from the fallback rather than the
142 369 // sitemap they configured.
143 370 'source' => self::$queue_source,
144 371 'sitemap_error' => $sitemap_error,
372 + // Which copies each URL gets, for the panel and the status
373 + // command. Each tick refreshes it: turning Separate Mobile
374 + // Cache on or off mid-crawl purges the cache, and the rest of
375 + // the crawl should fill the copies that now exist.
376 + 'devices' => self::devices(),
145 377 );
146 378 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
147 379
148 380 if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
@@ -241,15 +473,41 @@
241 473
242 474 $opts = Settings_Manager::get( 'preloader' );
243 475 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
244 476
245 - $processed_this_tick = 0;
246 - while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
247 - $url = array_shift( $state['queue'] );
477 + /**
478 + * Filter: xspeed_preload_batch_size
479 + *
480 + * How much one tick may warm, in the units the batch size setting
481 + * counts. The setting is the site owner's
482 + * intent; this is for anything that knows a lower ceiling applies —
483 + * a CDN with a rate limit in front, say, which will answer a burst
484 + * with a challenge and leave the queue looking warmed when it is not.
485 + *
486 + * Only ever lowers. A filter that raised it would let an add-on
487 + * overrule a number the site owner chose, and the reason to reach for
488 + * this is always that something cannot take the current rate.
489 + *
490 + * @param int $batch The batch this tick would otherwise use.
491 + */
492 + $ceiling = (int) apply_filters( 'xspeed_preload_batch_size', $batch );
493 + if ( $ceiling > 0 && $ceiling < $batch ) {
494 + $batch = $ceiling;
495 + }
496 +
497 + // batch_size caps requests, not URLs, so the load on the origin per
498 + // tick is the same with the phone copy on: each URL costs one request
499 + // per device. A tick always warms at least one URL.
500 + $devices = self::devices();
501 + $state['devices'] = $devices;
502 + $per_url = count( $devices );
503 + $requests = 0;
504 + while ( ! empty( $state['queue'] ) && ( 0 === $requests || $requests + $per_url <= $batch ) ) {
505 + $url = (string) array_shift( $state['queue'] );
248 506 self::warm_url( $url, $state );
249 507 $state['processed']++;
250 508 $state['last_url'] = $url;
251 - $processed_this_tick++;
509 + $requests += $per_url;
252 510 }
253 511
254 512 if ( empty( $state['queue'] ) ) {
255 513 self::mark_complete( $state );
@@ -275,32 +533,17 @@
275 533 public static function warm_one( string $url, string $cause = 'manual' ): bool {
276 534 if ( '' === $url ) {
277 535 return false;
278 536 }
279 - $response = wp_remote_get(
280 - $url,
281 - array(
282 - 'timeout' => self::REQUEST_TIMEOUT,
283 - 'sslverify' => false,
284 - 'user-agent' => self::USER_AGENT,
285 - 'blocking' => true,
286 - )
287 - );
288 - if ( is_wp_error( $response ) ) {
537 + $result = self::warm_devices( $url );
538 + foreach ( $result['failures'] as $failure ) {
289 539 Activity_Log::record(
290 540 'preloader_warm_failed',
291 - sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
541 + sprintf( 'Warm %s failed (%s): %s%s', $cause, $url, self::device_prefix( $failure['device'] ), $failure['error'] ),
292 542 Activity_Log::WARN
293 543 );
294 - return false;
295 544 }
296 - $code = (int) wp_remote_retrieve_response_code( $response );
297 - if ( $code >= 400 ) {
298 - Activity_Log::record(
299 - 'preloader_warm_failed',
300 - sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
301 - Activity_Log::WARN
302 - );
545 + if ( ! empty( $result['failures'] ) ) {
303 546 return false;
304 547 }
305 548 Activity_Log::record(
306 549 'preloader_warmed_one',
@@ -310,43 +553,22 @@
310 553 return true;
311 554 }
312 555
313 556 private static function warm_url( string $url, array &$state ): void {
314 - $response = wp_remote_get(
315 - $url,
316 - array(
317 - 'timeout' => self::REQUEST_TIMEOUT,
318 - 'sslverify' => false,
319 - 'user-agent' => self::USER_AGENT,
320 - 'headers' => array(
321 - 'Accept' => 'text/html,application/xhtml+xml',
322 - ),
323 - 'blocking' => true,
324 - )
325 - );
326 - if ( is_wp_error( $response ) ) {
557 + $result = self::warm_devices( $url, array( 'Accept' => 'text/html,application/xhtml+xml' ) );
558 + foreach ( $result['failures'] as $failure ) {
327 559 $state['errors'][] = array(
328 560 'url' => $url,
329 - 'error' => $response->get_error_message(),
561 + 'error' => self::device_prefix( $failure['device'] ) . $failure['error'],
330 562 'ts' => time(),
331 563 );
332 - // Cap retained errors so a broken sitemap doesn't blow the
333 - // transient size.
334 - $state['errors'] = array_slice( $state['errors'], -20 );
335 - return;
336 564 }
337 - $code = (int) wp_remote_retrieve_response_code( $response );
338 - if ( $code >= 400 ) {
339 - $state['errors'][] = array(
340 - 'url' => $url,
341 - 'error' => sprintf( 'HTTP %d', $code ),
342 - 'ts' => time(),
343 - );
344 - $state['errors'] = array_slice( $state['errors'], -20 );
345 - return;
565 + // Cap retained errors so a broken sitemap doesn't blow the
566 + // transient size.
567 + $state['errors'] = array_slice( $state['errors'], -20 );
568 + if ( '' !== $result['body'] ) {
569 + self::warm_remote_dimensions( $result['body'] );
346 570 }
347 -
348 - self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
349 571 }
350 572
351 573 /**
352 574 * Resolve dimensions for externally hosted images found on a warmed page.
@@ -606,9 +828,10 @@
606 828 $sitemap_url,
607 829 array(
608 830 'timeout' => self::REQUEST_TIMEOUT,
609 831 'sslverify' => false,
610 - 'user-agent' => self::USER_AGENT,
832 + 'user-agent' => self::user_agent(),
833 + 'headers' => Self_Traffic::headers(),
611 834 )
612 835 );
613 836 if ( is_wp_error( $res ) ) {
614 837 // Record WHY, don't just vanish. "Unreachable" and "valid but