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.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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/modules/Cache/CacheModule.php +281 -78 1.2.41.3.2 View file →
@@ -25,8 +25,91 @@
25 25 use XSpeed\Settings_Manager;
26 26
27 27 final class CacheModule extends Module {
28 28
29 + /**
30 + * Default Excluded Cookies.
31 + *
32 + * A constant for the same reason as DEFAULT_IGNORED_QUERY_PARAMS:
33 + * Cache::rewrite_block_lines() needs the list at boot, where building
34 + * the settings schema would translate its labels too early. The schema
35 + * and that fallback both read THIS, so they cannot drift.
36 + *
37 + * @var string[]
38 + */
39 + public const DEFAULT_EXCLUDED_COOKIES = array(
40 + 'comment_author',
41 + '~wordpress_[a-f0-9]+',
42 + 'wp-postpass',
43 + 'wordpress_no_cache',
44 + 'wordpress_logged_in',
45 + 'edd_items_in_cart',
46 + 'woocommerce_items_in_cart',
47 + 'fct_cart_hash',
48 + 'comment_',
49 + 'woocommerce_',
50 + 'wordpress',
51 + 'xf_',
52 + 'edd_',
53 + 'jetpack',
54 + 'yith_wcwl_session_',
55 + 'yith_wrvp_',
56 + 'wpsc_',
57 + 'ecwid',
58 + 'ec_',
59 + 'bookly',
60 + );
61 +
62 + /**
63 + * Default Ignored Query Parameters.
64 + *
65 + * A constant because Cache::sync_query_allowlist() needs this list at
66 + * boot, where building the settings schema would translate its labels
67 + * before WordPress allows it. Both the schema below and that boot-time
68 + * fallback read THIS, so the two cannot drift.
69 + *
70 + * @var string[]
71 + */
72 + public const DEFAULT_IGNORED_QUERY_PARAMS = array(
73 + '__s',
74 + '_ga',
75 + '_ke',
76 + '~[a-zA-Z0-9_-]+_sid',
77 + 'adgroupid',
78 + 'age-verified',
79 + 'ao_noptimize',
80 + 'campaignid',
81 + 'ck_subscriber_id',
82 + 'cn-reloaded',
83 + 'dclid',
84 + 'epik',
85 + 'fb_action_ids',
86 + 'fb_action_types',
87 + 'fb_source',
88 + 'fbclid',
89 + 'gclid',
90 + 'jobid',
91 + 'mc_cid',
92 + 'mc_eid',
93 + 'mkt_tok',
94 + 'msclkid',
95 + 'ref',
96 + // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
97 + // decorations. Enumerated because param names match
98 + // whole-name: the bare `ref` above no longer absorbs them,
99 + // and a `ref*` glob would over-match `referrer` and
100 + // `refund_id`, which are page-selecting.
101 + 'ref_src',
102 + 'ref_url',
103 + 'refid',
104 + '~session_[a-zA-Z0-9_-]+_alive',
105 + 'sseid',
106 + 'sslid',
107 + 'usqp',
108 + '~utm_[a-zA-Z0-9_-]+',
109 + );
110 +
111 +
29 112 public const SLUG = 'cache';
30 113 public const TIER = self::TIER_FREE;
31 114 public const VERSION = '1.0.0';
32 115
@@ -43,11 +126,11 @@
43 126 public const DEFAULT_EXPIRY_HOURS = 24 * 7;
44 127
45 128 public function ui_metadata(): array {
46 129 return array(
47 - 'label' => 'Page Cache',
130 + 'label' => __( 'Page Cache', 'xspeed' ),
48 131 'icon' => 'Database',
49 - 'description' => 'Page caching for non-logged-in visitors.',
132 + 'description' => __( 'Page caching for non-logged-in visitors.', 'xspeed' ),
50 133 );
51 134 }
52 135
53 136 /**
@@ -71,11 +154,11 @@
71 154 // disagreed about what "default" means. (#284)
72 155 'default' => self::DEFAULT_EXPIRY_HOURS,
73 156 'min' => 1,
74 157 'max' => 720,
75 - 'label' => 'Cache Expiry (hours)',
158 + 'label' => __( 'Cache Expiry (hours)', 'xspeed' ),
76 159 'unit' => 'hours',
77 - 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
160 + 'description' => __( 'How long cached pages live before regenerating. 1 to 720 hours (30 days).', 'xspeed' ),
78 161 ),
79 162 'excluded_urls' => array(
80 163 'type' => 'list',
81 164 // Comprehensive LiteSpeed / WP Rocket-parity default URL
@@ -101,10 +184,10 @@
101 184 '/edd-api',
102 185 '/wp-login',
103 186 ),
104 187 'item_type' => 'string',
105 - 'label' => 'Excluded URLs',
106 - 'description' => 'One pattern per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob for anchored matches (/cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs). Prefix with ~ for a raw regex (e.g. ~wp-.*\.php).',
188 + 'label' => __( 'Excluded URLs', 'xspeed' ),
189 + 'description' => __( 'One pattern per line. Plain text matches anywhere in the URL (e.g. /cart). Use glob for anchored matches (/cart/* matches /cart/items but not /foo/cart/bar; *.pdf matches PDFs). Prefix with ~ for a raw regex (e.g. ~wp-.*\.php).', 'xspeed' ),
107 190 ),
108 191 'excluded_cookies' => array(
109 192 'type' => 'list',
110 193 // Cookies that signal a logged-in / transactional visitor
@@ -109,40 +192,19 @@
109 192 'type' => 'list',
110 193 // Cookies that signal a logged-in / transactional visitor
111 194 // whose response must not be served from a shared cache.
112 195 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
113 - 'default' => array(
114 - 'comment_author',
115 - '~wordpress_[a-f0-9]+',
116 - 'wp-postpass',
117 - 'wordpress_no_cache',
118 - 'wordpress_logged_in',
119 - 'edd_items_in_cart',
120 - 'woocommerce_items_in_cart',
121 - 'fct_cart_hash',
122 - 'comment_',
123 - 'woocommerce_',
124 - 'wordpress',
125 - 'xf_',
126 - 'edd_',
127 - 'jetpack',
128 - 'yith_wcwl_session_',
129 - 'yith_wrvp_',
130 - 'wpsc_',
131 - 'ecwid',
132 - 'ec_',
133 - 'bookly',
134 - ),
196 + 'default' => self::DEFAULT_EXCLUDED_COOKIES,
135 197 'item_type' => 'string',
136 - 'label' => 'Excluded Cookies',
137 - 'description' => 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Plain text = "contains"; glob (woocommerce_*) and ~regex (~wordpress_[a-f0-9]+) supported. One per line.',
198 + 'label' => __( 'Excluded Cookies', 'xspeed' ),
199 + 'description' => __( 'Skip cache for any visitor whose request carries a cookie whose NAME matches one of these patterns. Plain text = "contains"; glob (woocommerce_*) and ~regex (~wordpress_[a-f0-9]+) supported. One per line.', 'xspeed' ),
138 200 ),
139 201 'bypass_user_agents' => array(
140 202 'type' => 'list',
141 203 'default' => array(),
142 204 'item_type' => 'string',
143 - 'label' => 'Bypass User Agents',
144 - 'description' => 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). Glob + ~regex supported. One per line.',
205 + 'label' => __( 'Bypass User Agents', 'xspeed' ),
206 + 'description' => __( 'Substring match against the visitor User-Agent. Matched UAs bypass cache (useful for screenshot bots, internal previews, monitoring). Glob + ~regex supported. One per line.', 'xspeed' ),
145 207 ),
146 208 'ignored_query_params' => array(
147 209 'type' => 'list',
148 210 // Analytics / ad / session query keys stripped before the
@@ -149,61 +211,24 @@
149 211 // cache key is computed, so /post?utm_source=x and /post
150 212 // share one entry. `~` prefix = raw regex. (FBS-82181)
151 213 // Matched whole-name, so every entry here means the param
152 214 // it names and nothing that merely contains it.
153 - 'default' => array(
154 - '__s',
155 - '_ga',
156 - '_ke',
157 - '~[a-zA-Z0-9_-]+_sid',
158 - 'adgroupid',
159 - 'age-verified',
160 - 'ao_noptimize',
161 - 'campaignid',
162 - 'ck_subscriber_id',
163 - 'cn-reloaded',
164 - 'dclid',
165 - 'epik',
166 - 'fb_action_ids',
167 - 'fb_action_types',
168 - 'fb_source',
169 - 'fbclid',
170 - 'gclid',
171 - 'jobid',
172 - 'mc_cid',
173 - 'mc_eid',
174 - 'mkt_tok',
175 - 'msclkid',
176 - 'ref',
177 - // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
178 - // decorations. Enumerated because param names match
179 - // whole-name: the bare `ref` above no longer absorbs them,
180 - // and a `ref*` glob would over-match `referrer` and
181 - // `refund_id`, which are page-selecting.
182 - 'ref_src',
183 - 'ref_url',
184 - 'refid',
185 - '~session_[a-zA-Z0-9_-]+_alive',
186 - 'sseid',
187 - 'sslid',
188 - 'usqp',
189 - '~utm_[a-zA-Z0-9_-]+',
190 - ),
215 + 'default' => self::DEFAULT_IGNORED_QUERY_PARAMS,
191 216 'item_type' => 'string',
192 - 'label' => 'Ignored Query Parameters',
193 - 'description' => 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Each entry matches a whole param name — plain text is an exact name, and glob (utm_*) or ~regex are anchored too, so "ref" does not also match "preference". One per line.',
217 + 'label' => __( 'Ignored Query Parameters', 'xspeed' ),
218 + 'description' => __( 'Query keys removed from the URL before computing the cache key, so /post?utm_source=x and /post share a cache entry. Defaults cover the common analytics + ad + session params. Each entry matches a whole param name — plain text is an exact name, and glob (utm_*) or ~regex are anchored too, so "ref" does not also match "preference". One per line.', 'xspeed' ),
194 219 ),
195 220 'purge_on_upgrade' => array(
196 221 'type' => 'bool',
197 222 'default' => true,
198 - 'label' => 'Purge After Updates',
199 - 'description' => 'Clear the page cache when a plugin, theme or WordPress core is updated. Cached HTML is produced by the code being replaced, so leaving it in place serves pre-update markup — and links to minified assets that no longer exist — until the cache expires. Translation updates are ignored, since a language pack changes no markup a cached page depends on. Updates to xSpeed itself always purge, regardless of this setting.',
223 + 'label' => __( 'Purge After Updates', 'xspeed' ),
224 + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress core is updated. Cached HTML is produced by the code being replaced, so leaving it in place serves pre-update markup — and links to minified assets that no longer exist — until the cache expires. Translation updates are ignored, since a language pack changes no markup a cached page depends on. Updates to xSpeed itself always purge, regardless of this setting.', 'xspeed' ),
200 225 ),
201 226 'mobile_separate' => array(
202 227 'type' => 'bool',
203 228 'default' => false,
204 - 'label' => 'Separate Mobile Cache',
205 - 'description' => 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.',
229 + 'label' => __( 'Separate Mobile Cache', 'xspeed' ),
230 + 'description' => __( 'Keep mobile and desktop responses in separate cache buckets. Turn on for AMP, mobile-specific themes (WPtouch / Jetpack mobile theme), or any setup that serves different HTML by device.', 'xspeed' ),
206 231 ),
207 232 );
208 233 }
209 234
@@ -352,14 +377,48 @@
352 377 'name' => 'budget',
353 378 'description' => 'Seconds to spend before stopping between steps. Default 120.',
354 379 'optional' => true,
355 380 ),
381 + array(
382 + 'type' => 'assoc',
383 + 'name' => 'measure-score',
384 + 'description' => 'auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run.',
385 + 'optional' => true,
386 + 'options' => array( 'auto', 'never', 'always' ),
387 + ),
356 388 ),
357 389 ),
358 390 array(
391 + 'name' => 'xspeed purge',
392 + 'callback' => array( $this, 'cli_purge' ),
393 + 'shortdesc' => 'Clear every cache xSpeed manages — page and static files, REST responses, minified assets, the object cache and the configured edge — and report per store what was cleared, what was skipped and why. Use --type to clear just one.',
394 + 'ai_hint' => 'Clear the cache after a change is live on the server but visitors still see the old version. Purges everything by default; --type=page for the local HTML only, --type=cloudflare for the edge only. Exits non-zero if a store that IS configured refused to purge, so its output can be trusted rather than assumed.',
395 + 'synopsis' => array(
396 + array(
397 + 'type' => 'assoc',
398 + 'name' => 'type',
399 + 'description' => 'What to clear: all (default), page, object, cloudflare, cdn — or a group name (edge). Comma-separate to clear several.',
400 + 'optional' => true,
401 + ),
402 + array(
403 + 'type' => 'assoc',
404 + 'name' => 'cause',
405 + 'description' => 'Label recorded in the purge log, so `wp xspeed cache purge-log` can tell this run apart from a click. Default "CLI".',
406 + 'optional' => true,
407 + ),
408 + array(
409 + 'type' => 'assoc',
410 + 'name' => 'format',
411 + 'description' => 'table (default, one line per store) or json (the full report, for scripts).',
412 + 'optional' => true,
413 + 'options' => array( 'table', 'json' ),
414 + ),
415 + ),
416 + ),
417 + array(
359 418 'name' => 'xspeed cache',
360 419 'callback' => array( $this, 'cli_handler' ),
361 - 'shortdesc' => 'Inspect the Cache module: `status` (settings), `inventory` (which pages are cached, and how old), `size` (where the disk usage goes), `purge-log` (what cleared the cache, when and why), `purge-url <url>` to clear one page, `recheck-rewrite` to re-run the static-rewrite probe, or `nginx-config` to print the unified nginx server-block for pasting into a vhost (site-wide purge / toggle use the dedicated commands).',
420 + 'shortdesc' => 'Inspect the Cache module: `status` (settings), `inventory` (which pages are cached, and how old), `size` (where the disk usage goes), `purge-log` (what cleared the cache, when and why), `purge-url <url>` to clear one page, `recheck-rewrite` to re-run the static-rewrite probe, or `nginx-config` to print the unified nginx server-block for pasting into a vhost. To clear the whole site use `wp xspeed purge`.',
362 421 'synopsis' => array(
363 422 array(
364 423 'type' => 'positional',
365 424 'name' => 'action',
@@ -411,8 +470,9 @@
411 470 array(
412 471 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
413 472 'dry_run' => isset( $assoc['dry-run'] ),
414 473 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
474 + 'measure_score' => (string) ( $assoc['measure-score'] ?? 'auto' ),
415 475 )
416 476 );
417 477
418 478 if ( is_wp_error( $result ) ) {
@@ -420,8 +480,14 @@
420 480 return;
421 481 }
422 482
423 483 if ( ! empty( $result['dry_run'] ) ) {
484 + // The summary carries the score AND its age. Printing the plan
485 + // without it left the one number a reader wants off the only
486 + // command they run before deciding to apply anything.
487 + if ( isset( $result['message'] ) ) {
488 + \WP_CLI::log( (string) $result['message'] );
489 + }
424 490 \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
425 491 foreach ( $result['plan'] as $step ) {
426 492 \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
427 493 }
@@ -445,10 +511,147 @@
445 511 \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? ' — ' . $row['fix'] : '' ) );
446 512 }
447 513
448 514 if ( ! empty( $result['applied'] ) ) {
449 - \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied and verified.' );
515 + // "applied and verified" was more than the checks earn. They read
516 + // HTML in PHP and cannot run JavaScript, so this line was telling
517 + // someone the site was fine when the only honest claim is that
518 + // nothing in the markup looked broken.
519 + \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied; HTML checks passed.' );
520 +
521 + if ( ! empty( $result['verify_urls'] ) ) {
522 + \WP_CLI::log( '' );
523 + \WP_CLI::log( 'Now open these and check they render, with no console errors:' );
524 + foreach ( $result['verify_urls'] as $u ) {
525 + \WP_CLI::log( ' ' . $u );
526 + }
527 + }
450 528 }
529 + }
530 +
531 + /**
532 + * `wp xspeed purge` — clear every cache xSpeed owns, in one call.
533 + *
534 + * Reports per store rather than printing a success banner, because the
535 + * banner was the bug: a site whose Cloudflare token had lost its purge
536 + * permission saw "cache cleared" and kept serving stale HTML from the
537 + * edge. What is skipped is as much of the answer as what is cleared, so
538 + * every skip prints its reason.
539 + *
540 + * Exit code follows the same distinction. A store that is not configured
541 + * has nothing to clear and does not fail the run — otherwise every CI
542 + * pipeline on a site without Redis goes red for a purge that did exactly
543 + * what it should. A store that IS configured and refused is a failure.
544 + *
545 + * There is deliberately no `--url`: WP-CLI reserves that flag for
546 + * multisite site selection and consumes it before a handler ever sees it.
547 + * Clearing one page is `wp xspeed cache purge-url <url>`.
548 + *
549 + * @param array<int,string> $args Positional args (unused).
550 + * @param array<string,string> $assoc Flags.
551 + */
552 + public function cli_purge( array $args, array $assoc ): void {
553 + unset( $args );
554 +
555 + $requested = array_values(
556 + array_filter(
557 + array_map( 'trim', explode( ',', (string) ( $assoc['type'] ?? 'all' ) ) )
558 + )
559 + );
560 + if ( ! $requested ) {
561 + $requested = array( 'all' );
562 + }
563 +
564 + $accepted = \XSpeed\Purge_Runner::accepted_types();
565 + $unknown = array_diff( $requested, $accepted );
566 + if ( $unknown ) {
567 + // Refuse before purging anything: a typo in --type must not
568 + // quietly clear a DIFFERENT store than the one named.
569 + \WP_CLI::error(
570 + sprintf(
571 + 'Unknown purge type: %s. Expected one of: %s',
572 + implode( ', ', $unknown ),
573 + implode( ', ', $accepted )
574 + )
575 + );
576 + return;
577 + }
578 +
579 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
580 + $report = \XSpeed\Purge_Runner::run( $requested, $cause );
581 +
582 + if ( 'json' === ( $assoc['format'] ?? 'table' ) ) {
583 + // The report goes to STDOUT alone so `... --format=json | jq` works;
584 + // the failure message goes to STDERR via ::error, which is also
585 + // what produces the non-zero exit.
586 + \WP_CLI::line( (string) wp_json_encode( $report ) );
587 + if ( ! $report['ok'] ) {
588 + \WP_CLI::error( 'One or more cache stores failed to purge; see the report above.' );
589 + }
590 + return;
591 + }
592 +
593 + $cleared = 0;
594 + $skipped = 0;
595 + $failed = 0;
596 + foreach ( $report['types'] as $row ) {
597 + switch ( $row['status'] ) {
598 + case \XSpeed\Purge_Runner::CLEARED:
599 + ++$cleared;
600 + \WP_CLI::log( sprintf( ' cleared %s%s', $row['label'], self::purge_amount( $row ) ) );
601 + break;
602 + case \XSpeed\Purge_Runner::FAILED:
603 + ++$failed;
604 + \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) );
605 + break;
606 + default:
607 + ++$skipped;
608 + \WP_CLI::log( sprintf( ' skipped %s — %s', $row['label'], $row['reason'] ) );
609 + }
610 + }
611 +
612 + if ( $failed ) {
613 + \WP_CLI::error(
614 + sprintf(
615 + '%d of %d cache store(s) failed to purge; %d cleared, %d skipped.',
616 + $failed,
617 + count( $report['types'] ),
618 + $cleared,
619 + $skipped
620 + )
621 + );
622 + return;
623 + }
624 +
625 + if ( ! $cleared ) {
626 + // Not a success banner: nothing was purged, and saying so is the
627 + // honest answer for a --type nobody has configured.
628 + \WP_CLI::log( sprintf( 'Nothing to purge — %d store(s) skipped.', $skipped ) );
629 + return;
630 + }
631 +
632 + \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) );
633 + }
634 +
635 + /**
636 + * The " — 42 entries (1.3 MB)" tail on a cleared line.
637 + *
638 + * Entries and bytes are both optional: a Redis FLUSHALL reports neither,
639 + * and printing "0 entries" for it would read as an empty cache rather
640 + * than an uncountable one.
641 + *
642 + * @param array{entries:int|null,bytes:int|null} $row Report row.
643 + */
644 + private static function purge_amount( array $row ): string {
645 + $parts = array();
646 + if ( null !== $row['entries'] ) {
647 + $parts[] = sprintf( '%d entr%s', $row['entries'], 1 === (int) $row['entries'] ? 'y' : 'ies' );
648 + }
649 + if ( null !== $row['bytes'] && $row['bytes'] > 0 ) {
650 + $parts[] = size_format( $row['bytes'], 1 );
651 + }
652 +
653 + return $parts ? ' — ' . implode( ', ', $parts ) : '';
451 654 }
452 655
453 656 public function cli_handler( array $args, array $assoc ): void {
454 657 $action = isset( $args[0] ) ? (string) $args[0] : 'status';