PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Cache/CacheModule.php +225 -20 1.3.0 → 1.3.7 View file →
@@ -107,9 +107,48 @@
107 107 'usqp',
108 108 '~utm_[a-zA-Z0-9_-]+',
109 109 );
110 110
111 + /**
112 + * Click and campaign IDs added to the defaults in 1.3.6.
113 + *
114 + * Each is unique per click and never changes the page, yet each one
115 + * bypassed the page cache and, with xSpeed Pro, was a new CSS entry to
116 + * build: Google Merchant's `srsltid` and Ads' `gad_*`/`gbraid`/`wbraid`,
117 + * GA4's cross-domain `_gl`, TikTok, X, Instagram, Yandex, HubSpot email
118 + * and LinkedIn IDs. Kept as their own list so the upgrade can add exactly
119 + * these to a saved list without re-adding anything a site removed.
120 + *
121 + * @var string[]
122 + */
123 + public const TRACKING_PARAMS_1_3_6 = array(
124 + '_gl',
125 + '_hsenc',
126 + '_hsmi',
127 + 'gad_campaignid',
128 + 'gad_source',
129 + 'gbraid',
130 + 'igshid',
131 + 'li_fat_id',
132 + 'srsltid',
133 + 'ttclid',
134 + 'twclid',
135 + 'wbraid',
136 + 'yclid',
137 + );
111 138
139 +
140 + /**
141 + * The shipped default list: the base list plus later additions, sorted.
142 + *
143 + * @return string[]
144 + */
145 + public static function default_ignored_query_params(): array {
146 + $all = array_values( array_unique( array_merge( self::DEFAULT_IGNORED_QUERY_PARAMS, self::TRACKING_PARAMS_1_3_6 ) ) );
147 + sort( $all );
148 + return $all;
149 + }
150 +
112 151 public const SLUG = 'cache';
113 152 public const TIER = self::TIER_FREE;
114 153 public const VERSION = '1.0.0';
115 154
@@ -128,9 +167,10 @@
128 167 public function ui_metadata(): array {
129 168 return array(
130 169 'label' => __( 'Page Cache', 'xspeed' ),
131 170 'icon' => 'Database',
132 - 'description' => __( 'Page caching for non-logged-in visitors.', 'xspeed' ),
171 + 'description' => __( 'Saves each page as a file and serves it to logged-out visitors.', 'xspeed' ),
172 + 'group' => 'cache',
133 173 );
134 174 }
135 175
136 176 /**
@@ -145,9 +185,9 @@
145 185 return array();
146 186 }
147 187
148 188 public function settings_schema(): array {
149 - return array(
189 + $schema = array(
150 190 'cache_expiry' => array(
151 191 'type' => 'int',
152 192 // Matches the wizard's Balanced preset, which is what a fresh
153 193 // install starts on — a shorter module default meant the two
@@ -154,11 +194,11 @@
154 194 // disagreed about what "default" means. (#284)
155 195 'default' => self::DEFAULT_EXPIRY_HOURS,
156 196 'min' => 1,
157 197 'max' => 720,
158 - 'label' => __( 'Cache Expiry (hours)', 'xspeed' ),
198 + 'label' => __( 'Cache expiry (hours)', 'xspeed' ),
159 199 'unit' => 'hours',
160 - 'description' => __( 'How long cached pages live before regenerating. 1 to 720 hours (30 days).', 'xspeed' ),
200 + 'description' => __( 'How long a cached page is kept before xSpeed builds it again. 1 to 720 hours (30 days).', 'xspeed' ),
161 201 ),
162 202 'excluded_urls' => array(
163 203 'type' => 'list',
164 204 // Comprehensive LiteSpeed / WP Rocket-parity default URL
@@ -170,9 +210,11 @@
170 210 '/xmlrpc.php',
171 211 '~wp-.*\.php',
172 212 '/feed/',
173 213 'index.php',
174 - '~sitemap(_index)?\.xml',
214 + // `sitemaps?` — SEOPress generates sitemaps.xml (plural).
215 + '~sitemaps?(_index)?\.xml',
216 + '/robots.txt',
175 217 // Bare (no trailing slash) so "contains" matches both
176 218 // /cart and /cart/items — WooCommerce serves both forms.
177 219 '/cart',
178 220 '/checkout',
@@ -185,9 +227,9 @@
185 227 '/wp-login',
186 228 ),
187 229 'item_type' => 'string',
188 230 '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' ),
231 + 'description' => __( 'Pages whose URL matches a line here are never cached. Plain text matches anywhere (/cart). A pattern with * matches from the start of the path: /cart/* matches /cart/items but not /shop/cart/items. ~ starts a regex.', 'xspeed' ),
190 232 ),
191 233 'excluded_cookies' => array(
192 234 'type' => 'list',
193 235 // Cookies that signal a logged-in / transactional visitor
@@ -194,17 +236,17 @@
194 236 // whose response must not be served from a shared cache.
195 237 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
196 238 'default' => self::DEFAULT_EXCLUDED_COOKIES,
197 239 'item_type' => 'string',
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' ),
240 + 'label' => __( 'Excluded cookies', 'xspeed' ),
241 + 'description' => __( 'Visitors with a cookie whose name matches a line here always get a fresh page. One per line; * and ~regex work.', 'xspeed' ),
200 242 ),
201 243 'bypass_user_agents' => array(
202 244 'type' => 'list',
203 245 'default' => array(),
204 246 'item_type' => 'string',
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' ),
247 + 'label' => __( 'Excluded browsers and bots', 'xspeed' ),
248 + 'description' => __( 'Visitors whose user agent contains a line here always get a fresh page. Useful for screenshot bots and uptime monitors.', 'xspeed' ),
207 249 ),
208 250 'ignored_query_params' => array(
209 251 'type' => 'list',
210 252 // Analytics / ad / session query keys stripped before the
@@ -211,26 +253,94 @@
211 253 // cache key is computed, so /post?utm_source=x and /post
212 254 // share one entry. `~` prefix = raw regex. (FBS-82181)
213 255 // Matched whole-name, so every entry here means the param
214 256 // it names and nothing that merely contains it.
215 - 'default' => self::DEFAULT_IGNORED_QUERY_PARAMS,
257 + 'default' => self::default_ignored_query_params(),
216 258 'item_type' => 'string',
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' ),
259 + 'label' => __( 'Ignored query parameters', 'xspeed' ),
260 + 'advanced' => true,
261 + 'description' => __( 'URL parameters to ignore, so /post?utm_source=x gets the same cached page as /post. Each line matches a whole parameter name.', 'xspeed' ),
219 262 ),
220 263 'purge_on_upgrade' => array(
221 264 'type' => 'bool',
222 265 'default' => true,
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' ),
266 + 'label' => __( 'Clear cache after updates', 'xspeed' ),
267 + 'description' => __( 'Clear the page cache when a plugin, theme or WordPress is updated, so visitors never get old pages with broken asset links.', 'xspeed' ),
225 268 ),
226 269 'mobile_separate' => array(
227 270 'type' => 'bool',
228 271 'default' => false,
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' ),
272 + 'label' => __( 'Separate mobile cache', 'xspeed' ),
273 + 'description' => __( 'Keep a separate cached copy for phones. Turn on only if your theme or plugins show different pages on mobile.', 'xspeed' ),
231 274 ),
275 + 'edge_provider' => array(
276 + 'type' => 'enum',
277 + 'default' => 'auto',
278 + 'options' => array( 'auto', 'off', 'cloudflare', 'fastly', 'varnish', 'nginx', 'akamai', 'cloudfront', 'google', 'keycdn', 'bunny', 'sucuri', 'incapsula', 'generic', 'custom' ),
279 + 'option_labels' => array(
280 + 'auto' => 'Detect automatically',
281 + 'off' => 'Off — send nothing',
282 + 'cloudflare' => 'Cloudflare',
283 + 'varnish' => 'Varnish',
284 + 'nginx' => 'nginx proxy cache',
285 + 'cloudfront' => 'Amazon CloudFront',
286 + 'google' => 'Google Cloud CDN',
287 + 'keycdn' => 'KeyCDN',
288 + 'bunny' => 'Bunny',
289 + // These four cannot be presented as supported on the same
290 + // footing as the ones above. Vendor documentation either
291 + // does not establish that they honour what we send, or
292 + // establishes that they ignore origin cache headers until
293 + // the property is configured to respect them — Akamai
294 + // caches for a theoretically infinite time by default, and
295 + // Sucuri's default caching level ignores the headers
296 + // outright. Naming them without the caveat would promise a
297 + // protection the CDN is not currently giving.
298 + 'fastly' => 'Fastly (needs CDN configuration)',
299 + 'akamai' => 'Akamai (needs CDN configuration)',
300 + 'sucuri' => 'Sucuri (needs CDN configuration)',
301 + 'incapsula' => 'Imperva / Incapsula (needs CDN configuration)',
302 + 'generic' => 'Something else',
303 + 'custom' => 'Custom headers',
304 + ),
305 + 'label' => __( 'CDN or proxy in front', 'xspeed' ),
306 + 'description' => __( 'Tells your CDN or proxy not to store pages xSpeed does not cache. Leave on Detect automatically unless you know your provider.', 'xspeed' ),
307 + 'advanced' => true,
308 + 'info_title' => __( 'Cache in front of this site', 'xspeed' ),
309 + 'info' => __( 'Naming your provider narrows the headers to the one it reads. Detect automatically works it out per request and otherwise sends a set every cache ignores unless it understands it, so it is safe not to know. Run "wp xspeed cache edge" to see what was detected and what gets sent.', 'xspeed' )
310 + ),
311 + 'edge_custom_headers' => array(
312 + 'type' => 'list',
313 + 'default' => array(),
314 + 'item_type' => 'string',
315 + 'label' => __( 'Custom CDN headers', 'xspeed' ),
316 + 'advanced' => true,
317 + 'dependsOn' => array( 'field' => 'edge_provider', 'value' => 'custom' ),
318 + 'description' => __( 'One header per line, as Name: value, for example "Surrogate-Control: no-store". Lines starting with # are ignored.', 'xspeed' ),
319 + 'info_title' => __( 'Custom edge headers', 'xspeed' ),
320 + 'info' => __( 'These replace the headers xSpeed would have picked for your CDN. Two baselines are still added underneath: a Cache-Control, and "X-Accel-Expires: 0" for a page cache running in nginx on your own server. Name either one yourself and yours is used instead. Values containing $, % or a backslash are dropped — the same pairs go into nginx and Apache directives, where those cannot be escaped safely. Content-Length, Content-Encoding, Content-Type, Transfer-Encoding, Set-Cookie and Location are refused.', 'xspeed' )
321 + ),
232 322 );
323 +
324 + // LiteSpeed-only opt-in (#509): meaningless on any other server, so
325 + // the field only exists in the schema where it can act — elsewhere the
326 + // stored value survives via preserved_keys(). Inserted right after
327 + // mobile_separate, its sibling static-fast-path trade-off.
328 + if ( \XSpeed\Server::LITESPEED === \XSpeed\Server::type() ) {
329 + $litespeed = array(
330 + 'litespeed_static_rewrite' => array(
331 + 'type' => 'bool',
332 + 'default' => false,
333 + 'label' => __( 'LiteSpeed static fast path', 'xspeed' ),
334 + 'advanced' => true,
335 + 'description' => __( 'LiteSpeed serves cached pages without running PHP, which is faster on slow hosts. These visits are not counted in the dashboard hit ratio.', 'xspeed' ),
336 + ),
337 + );
338 + $pos = (int) array_search( 'mobile_separate', array_keys( $schema ), true ) + 1;
339 + $schema = array_slice( $schema, 0, $pos, true ) + $litespeed + array_slice( $schema, $pos, null, true );
340 + }
341 +
342 + return $schema;
233 343 }
234 344
235 345 /**
236 346 * `mobile_separate_review` lives outside the schema: migration sets it
@@ -243,9 +353,18 @@
243 353 *
244 354 * @return string[]
245 355 */
246 356 public function preserved_keys(): array {
247 - return array( 'mobile_separate_review' );
357 + $keys = array( 'mobile_separate_review' );
358 + // On non-LiteSpeed servers the litespeed_static_rewrite field is not
359 + // in the schema (see settings_schema()), so a schema-driven save
360 + // would silently drop a value chosen while the site ran LiteSpeed.
361 + // Preserve it so moving LiteSpeed → other → LiteSpeed keeps the
362 + // user's choice. On LiteSpeed itself the schema owns the key.
363 + if ( \XSpeed\Server::LITESPEED !== \XSpeed\Server::type() ) {
364 + $keys[] = 'litespeed_static_rewrite';
365 + }
366 + return $keys;
248 367 }
249 368
250 369 /**
251 370 * Seed per-module option from the legacy xspeed_options blob if we
@@ -416,14 +535,14 @@
416 535 ),
417 536 array(
418 537 'name' => 'xspeed cache',
419 538 'callback' => array( $this, 'cli_handler' ),
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`.',
539 + '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, or `edge` to show which cache is in front of the site and what xSpeed tells it. To clear the whole site use `wp xspeed purge`.',
421 540 'synopsis' => array(
422 541 array(
423 542 'type' => 'positional',
424 543 'name' => 'action',
425 - 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config' ),
544 + 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge' ),
426 545 'optional' => true,
427 546 ),
428 547 array(
429 548 'type' => 'positional',
@@ -832,13 +951,99 @@
832 951 $this->cli_purge_log( $limit );
833 952 return;
834 953 }
835 954
955 + if ( 'edge' === $action ) {
956 + $this->cli_edge();
957 + return;
958 + }
959 +
836 960 $opts = Settings_Manager::get( self::SLUG );
837 961 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
838 962 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
839 963 foreach ( $opts['excluded_urls'] as $u ) {
840 964 \WP_CLI::log( ' - ' . $u );
965 + }
966 + $edge = \XSpeed\Edge_Provider::detect();
967 + \WP_CLI::log( 'edge ' . ( '' !== $edge['provider'] ? $edge['provider'] : $edge['confidence'] ) . ' (' . $edge['source'] . ')' );
968 + }
969 +
970 + /**
971 + * `wp xspeed cache edge` — what we think is in front, and what we say to it.
972 + *
973 + * Worth printing even when nothing is held back. "You are behind
974 + * Cloudflare, and a Cache Rule set to ignore origin headers overrides
975 + * anything xSpeed sends" is the answer to a support question that
976 + * otherwise costs someone a week, and it is true whether or not a hold
977 + * ever fires.
978 + */
979 + private function cli_edge(): void {
980 + $answer = \XSpeed\Edge_Provider::detect();
981 +
982 + \WP_CLI::log( 'provider ' . ( '' !== $answer['provider'] ? $answer['provider'] : '(none named)' ) );
983 + \WP_CLI::log( 'confidence ' . $answer['confidence'] );
984 + \WP_CLI::log( 'source ' . $answer['source'] );
985 +
986 + // A pin outranks detection by design, so nothing re-checks it on the
987 + // site's behalf. Saying the two disagree is the whole mechanism by
988 + // which a site that changed CDN ever finds out.
989 + $sniffed = \XSpeed\Edge_Provider::sniffed();
990 + if ( in_array( $answer['source'], array( 'setting', 'constant', 'filter' ), true )
991 + && '' !== $sniffed['provider']
992 + && $sniffed['provider'] !== $answer['provider'] ) {
993 + \WP_CLI::warning(
994 + sprintf(
995 + 'This request looks like %s, but the provider is pinned to %s. If the site moved, change it — the pinned answer is also baked into the drop-in and the server rules.',
996 + $sniffed['provider'],
997 + '' !== $answer['provider'] ? $answer['provider'] : 'off'
998 + )
999 + );
1000 + }
1001 +
1002 + if ( \XSpeed\Edge_Provider::is_off( $answer ) ) {
1003 + \WP_CLI::log( '' );
1004 + \WP_CLI::log( 'Nothing is sent: this is switched off.' );
1005 + return;
1006 + }
1007 +
1008 + // Resolved through edge_headers_for() rather than straight off the
1009 + // provider, so this prints what the serve path would ACTUALLY send —
1010 + // including `X-XSpeed-Edge-Hold`, and including the evidence gate.
1011 + // Listing the provider's raw set ignored that gate and told operators
1012 + // a first render would be held on a site where it would not be.
1013 + //
1014 + // `bake`, not `request`. Two reasons, and the second one matters:
1015 + // this command answers for the site rather than for one response, and
1016 + // `request` fires `xspeed_edge_optimization_pending`, whose Pro
1017 + // listener resolves the CSS plan — which by its own description is
1018 + // what queues a build. A read-only command must not burn a build
1019 + // slot, quarantine an entry or purge a page just by being run, and
1020 + // under WP-CLI it would do all three against the home page.
1021 + $bypass = \XSpeed\Cache::edge_headers_for( 'BYPASS', 'bake', 'logged-in' );
1022 + $miss = \XSpeed\Cache::edge_headers_for( 'MISS', 'bake' );
1023 +
1024 + \WP_CLI::log( '' );
1025 + \WP_CLI::log( 'On a page xSpeed refuses to cache (a cart, a logged-in view):' );
1026 + foreach ( $bypass as $name => $value ) {
1027 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1028 + }
1029 +
1030 + \WP_CLI::log( '' );
1031 + if ( array() === $miss ) {
1032 + \WP_CLI::log( 'On a first render: nothing. A MISS is a performance hedge, so it is held only where a cache in front was detected — and none was. Name the provider in Cache In Front Of This Site to cover first renders too.' );
1033 + } else {
1034 + \WP_CLI::log( 'On a first render:' );
1035 + foreach ( $miss as $name => $value ) {
1036 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1037 + }
1038 + }
1039 +
1040 + \WP_CLI::log( '' );
1041 + \WP_CLI::log( 'X-XSpeed-Edge-Hold names why a response was held: bypass, bypass-shape, miss, mobile-split or pending. No header means nothing was held.' );
1042 +
1043 + if ( 'cloudflare' === $answer['provider'] ) {
1044 + \WP_CLI::log( '' );
1045 + \WP_CLI::log( 'A Cloudflare Cache Rule whose Edge TTL is "Ignore cache-control header and use this TTL" overrides all of the above. Use "Respect origin TTL" on that rule if pages are still being stored.' );
841 1046 }
842 1047 }
843 1048
844 1049 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */