PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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
xspeed / includes / modules / Cache / CacheModule.php

CacheModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/modules/Cache/CacheModule.php

546 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache module.
4 *
5 * Owns the cache_expiry and excluded_urls settings. cache_enabled is
6 * deliberately NOT in this schema — flipping it triggers the
7 * advanced-cache.php drop-in install + WP_CACHE constant edit in
8 * wp-config.php, which is a sensitive single-purpose code path and lives
9 * in Cache::toggle() with its own dedicated /xspeed/v1/cache/toggle REST
10 * route. The dashboard's Cache page renders the special hero UI for it
11 * above this module's schema-driven settings panel.
12 *
13 * Tier: Free.
14 *
15 * @package XSpeed
16 */
17
18 declare(strict_types=1);
19
20 namespace XSpeed\Modules\Cache;
21
22 defined( 'ABSPATH' ) || exit;
23
24 use XSpeed\Module;
25 use XSpeed\Settings_Manager;
26
27 final class CacheModule extends Module {
28
29 public const SLUG = 'cache';
30 public const TIER = self::TIER_FREE;
31 public const VERSION = '1.0.0';
32
33 public function ui_metadata(): array {
34 return array(
35 'label' => 'Page Cache',
36 'icon' => 'Database',
37 'description' => 'Page caching for non-logged-in visitors.',
38 );
39 }
40
41 public function settings_schema(): array {
42 return array(
43 'cache_expiry' => array(
44 'type' => 'int',
45 'default' => 24,
46 'min' => 1,
47 'max' => 720,
48 'label' => 'Cache Expiry (hours)',
49 'unit' => 'hours',
50 'description' => 'How long cached pages live before regenerating. 1 to 720 hours (30 days).',
51 ),
52 'excluded_urls' => array(
53 'type' => 'list',
54 // Comprehensive LiteSpeed / WP Rocket-parity default URL
55 // exclusions (FBS-82181). Plain text = "contains", glob via
56 // * ? [ ], or a `~` prefix for raw regex (e.g. ~wp-.*\.php).
57 'default' => array(
58 '/wp-admin/',
59 '/wp-json/',
60 '/xmlrpc.php',
61 '~wp-.*\.php',
62 '/feed/',
63 'index.php',
64 '~sitemap(_index)?\.xml',
65 // Bare (no trailing slash) so "contains" matches both
66 // /cart and /cart/items — WooCommerce serves both forms.
67 '/cart',
68 '/checkout',
69 '/my-account',
70 'ao_noptirocket',
71 'ao_speedup_cachebuster',
72 'removed_item',
73 '/wc-api',
74 '/edd-api',
75 '/wp-login',
76 ),
77 'item_type' => 'string',
78 'label' => 'Excluded URLs',
79 '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).',
80 ),
81 'excluded_cookies' => array(
82 'type' => 'list',
83 // Cookies that signal a logged-in / transactional visitor
84 // whose response must not be served from a shared cache.
85 // `~` prefix = raw regex (e.g. ~wordpress_[a-f0-9]+). (FBS-82181)
86 'default' => array(
87 'comment_author',
88 '~wordpress_[a-f0-9]+',
89 'wp-postpass',
90 'wordpress_no_cache',
91 'wordpress_logged_in',
92 'edd_items_in_cart',
93 'woocommerce_items_in_cart',
94 'fct_cart_hash',
95 'comment_',
96 'woocommerce_',
97 'wordpress',
98 'xf_',
99 'edd_',
100 'jetpack',
101 'yith_wcwl_session_',
102 'yith_wrvp_',
103 'wpsc_',
104 'ecwid',
105 'ec_',
106 'bookly',
107 ),
108 'item_type' => 'string',
109 'label' => 'Excluded Cookies',
110 '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.',
111 ),
112 'bypass_user_agents' => array(
113 'type' => 'list',
114 'default' => array(),
115 'item_type' => 'string',
116 'label' => 'Bypass User Agents',
117 '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.',
118 ),
119 'ignored_query_params' => array(
120 'type' => 'list',
121 // Analytics / ad / session query keys stripped before the
122 // cache key is computed, so /post?utm_source=x and /post
123 // share one entry. `~` prefix = raw regex. (FBS-82181)
124 // Matched whole-name, so every entry here means the param
125 // it names and nothing that merely contains it.
126 'default' => array(
127 '__s',
128 '_ga',
129 '_ke',
130 '~[a-zA-Z0-9_-]+_sid',
131 'adgroupid',
132 'age-verified',
133 'ao_noptimize',
134 'campaignid',
135 'ck_subscriber_id',
136 'cn-reloaded',
137 'dclid',
138 'epik',
139 'fb_action_ids',
140 'fb_action_types',
141 'fb_source',
142 'fbclid',
143 'gclid',
144 'jobid',
145 'mc_cid',
146 'mc_eid',
147 'mkt_tok',
148 'msclkid',
149 'ref',
150 // Twitter/X (`ref_src`, `ref_url`) and Facebook (`refid`)
151 // decorations. Enumerated because param names match
152 // whole-name: the bare `ref` above no longer absorbs them,
153 // and a `ref*` glob would over-match `referrer` and
154 // `refund_id`, which are page-selecting.
155 'ref_src',
156 'ref_url',
157 'refid',
158 '~session_[a-zA-Z0-9_-]+_alive',
159 'sseid',
160 'sslid',
161 'usqp',
162 '~utm_[a-zA-Z0-9_-]+',
163 ),
164 'item_type' => 'string',
165 'label' => 'Ignored Query Parameters',
166 '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.',
167 ),
168 'mobile_separate' => array(
169 'type' => 'bool',
170 'default' => false,
171 'label' => 'Separate Mobile Cache',
172 '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.',
173 ),
174 );
175 }
176
177 /**
178 * `mobile_separate_review` lives outside the schema: migration sets it
179 * (bool) when a source plugin had "separate mobile cache" on, so the
180 * dashboard can prompt the user to re-enable it deliberately instead of
181 * silently importing it (which would kill the device-blind static fast
182 * path). Without preserving it here, the first schema-driven cache save
183 * would rebuild the option from the schema alone and drop the flag before
184 * the user ever saw the prompt. (FBS-83145)
185 *
186 * @return string[]
187 */
188 public function preserved_keys(): array {
189 return array( 'mobile_separate_review' );
190 }
191
192 /**
193 * Seed per-module option from the legacy xspeed_options blob if we
194 * haven't done so yet. Idempotent — once xspeed_module_cache exists
195 * or the legacy keys are gone, this is a no-op. Runs on both boot
196 * and activate so installs on every code path are covered.
197 */
198 public function boot(): void {
199 $this->seed_from_legacy_if_needed();
200
201 // Keep every mobile_separate-dependent artifact (the drop-in's
202 // `.mobile-separate` flag, the device-blind server rewrite, and the
203 // device-keyed caches) in lockstep with the setting — on boot, and
204 // whenever the cache settings are saved. The drop-in can't read WP
205 // options, so it reads the sidecar marker Cache maintains here.
206 \XSpeed\Cache::reconcile_mobile_separate();
207 add_action(
208 'update_option_xspeed_module_cache',
209 static function () {
210 \XSpeed\Cache::reconcile_mobile_separate();
211 // Re-bake the cookie / user-agent exclusion rules into the
212 // drop-in. It runs before WordPress loads and so carries a
213 // COPY of those rules, substituted at install time — and
214 // auto_heal() deliberately only reinstalls when the file is
215 // missing, foreign, or an older version, none of which a
216 // settings change makes true. Without this, adding an
217 // excluded cookie left the drop-in serving the shared
218 // anonymous page to exactly the visitors it excluded, until
219 // the next plugin upgrade happened to reinstall it.
220 \XSpeed\Cache::install_dropin();
221 // Same staleness applies to the .htaccess block, which is
222 // written to disk from the same generator. Refresh it only
223 // when a block is already installed — writing one here would
224 // enable the static path on a site that never opted in.
225 \XSpeed\Cache::refresh_rewrite_if_installed();
226 }
227 );
228
229 // Time-driven collection of expired entries and superseded minified
230 // assets. Scheduled here as well as in activate() because a site that
231 // upgrades into this version never runs the activation hook again.
232 add_action( \XSpeed\Cache_GC::CRON_HOOK, array( \XSpeed\Cache_GC::class, 'run' ) );
233 \XSpeed\Cache_GC::ensure_scheduled();
234 }
235
236 public function activate(): void {
237 $this->seed_from_legacy_if_needed();
238 \XSpeed\Cache_GC::ensure_scheduled();
239 }
240
241 public function deactivate(): void {
242 \XSpeed\Cache_GC::unschedule();
243 }
244
245 private function seed_from_legacy_if_needed(): void {
246 if ( null !== get_option( 'xspeed_module_cache', null ) ) {
247 return;
248 }
249 $legacy = get_option( 'xspeed_options', array() );
250 if ( ! is_array( $legacy ) ) {
251 return;
252 }
253 $seed = array( '_version' => self::VERSION );
254 $dirty = false;
255 if ( array_key_exists( 'cache_expiry', $legacy ) ) {
256 $seed['cache_expiry'] = max( 1, min( 720, (int) $legacy['cache_expiry'] ) );
257 unset( $legacy['cache_expiry'] );
258 $dirty = true;
259 }
260 if ( array_key_exists( 'excluded_urls', $legacy ) ) {
261 $seed['excluded_urls'] = is_array( $legacy['excluded_urls'] ) ? array_values( array_filter( $legacy['excluded_urls'], 'is_string' ) ) : array();
262 unset( $legacy['excluded_urls'] );
263 $dirty = true;
264 }
265 if ( $dirty ) {
266 update_option( 'xspeed_module_cache', $seed );
267 update_option( 'xspeed_options', $legacy );
268 }
269 }
270
271 public function cli_commands(): array {
272 return array(
273 array(
274 'name' => 'xspeed optimize',
275 'callback' => array( $this, 'cli_optimize' ),
276 'shortdesc' => 'Measure, apply the recommended settings one at a time, verify the page still works after each, and report what changed. Use --dry-run to see the plan without touching anything.',
277 'synopsis' => array(
278 array(
279 'type' => 'assoc',
280 'name' => 'aggressiveness',
281 'description' => 'safe (removals + server-side only), standard (default), or aggressive (includes settings known to break some themes).',
282 'optional' => true,
283 'options' => array( 'safe', 'standard', 'aggressive' ),
284 ),
285 array(
286 'type' => 'flag',
287 'name' => 'dry-run',
288 'description' => 'Show the plan and stop. Changes nothing.',
289 'optional' => true,
290 ),
291 array(
292 'type' => 'assoc',
293 'name' => 'budget',
294 'description' => 'Seconds to spend before stopping between steps. Default 120.',
295 'optional' => true,
296 ),
297 ),
298 ),
299 array(
300 'name' => 'xspeed cache',
301 'callback' => array( $this, 'cli_handler' ),
302 '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, or `recheck-rewrite` to re-run the static-rewrite probe (site-wide purge / toggle use the dedicated commands).',
303 'synopsis' => array(
304 array(
305 'type' => 'positional',
306 'name' => 'action',
307 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite' ),
308 'optional' => true,
309 ),
310 array(
311 'type' => 'positional',
312 'name' => 'url',
313 'optional' => true,
314 ),
315 array(
316 'type' => 'assoc',
317 'name' => 'limit',
318 'description' => 'Rows to print for inventory / purge-log. Default 20.',
319 'optional' => true,
320 ),
321 array(
322 'type' => 'assoc',
323 'name' => 'cause',
324 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
325 'optional' => true,
326 ),
327 ),
328 ),
329 );
330 }
331
332 /**
333 * `wp xspeed optimize` — run the autopilot.
334 *
335 * Prints what it DID, not what it hoped to do: applied steps, reverted
336 * steps with the reason they were undone, and the problems it could not
337 * touch. A run that changes nothing prints that plainly rather than a
338 * success banner.
339 *
340 * @param array<int,string> $args Positional args (unused).
341 * @param array<string,string> $assoc Flags.
342 */
343 public function cli_optimize( array $args, array $assoc ): void {
344 $result = \XSpeed\Optimize_Runner::run(
345 array(
346 'aggressiveness' => (string) ( $assoc['aggressiveness'] ?? 'standard' ),
347 'dry_run' => isset( $assoc['dry-run'] ),
348 'budget_seconds' => isset( $assoc['budget'] ) ? (int) $assoc['budget'] : 120,
349 )
350 );
351
352 if ( is_wp_error( $result ) ) {
353 \WP_CLI::error( $result->get_error_message() );
354 return;
355 }
356
357 if ( ! empty( $result['dry_run'] ) ) {
358 \WP_CLI::log( 'Plan (' . count( $result['plan'] ) . ' steps, nothing applied):' );
359 foreach ( $result['plan'] as $step ) {
360 \WP_CLI::log( ' - ' . $step['change'] . ' [' . $step['tier'] . ']' );
361 }
362 foreach ( $result['skipped'] as $row ) {
363 \WP_CLI::log( ' skipped: ' . $row['id'] . '' . $row['why'] );
364 }
365 return;
366 }
367
368 if ( isset( $result['message'] ) ) {
369 \WP_CLI::success( (string) $result['message'] );
370 }
371
372 foreach ( $result['applied'] as $row ) {
373 \WP_CLI::log( '' . $row['change'] );
374 }
375 foreach ( $result['reverted'] as $row ) {
376 \WP_CLI::warning( 'Undone: ' . $row['id'] . '' . $row['why'] );
377 }
378 foreach ( $result['unfixable'] as $row ) {
379 \WP_CLI::log( ' ! ' . $row['issue'] . ( '' !== $row['fix'] ? '' . $row['fix'] : '' ) );
380 }
381
382 if ( ! empty( $result['applied'] ) ) {
383 \WP_CLI::success( count( $result['applied'] ) . ' change(s) applied and verified.' );
384 }
385 }
386
387 public function cli_handler( array $args, array $assoc ): void {
388 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
389 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
390
391 /*
392 * Force a fresh static-rewrite probe. The result is cached for five
393 * minutes and nothing invalidated it, so after fixing an nginx config
394 * there was no way to re-check — the "configure your server" banner
395 * just stayed up. (FBS-84012)
396 */
397 if ( 'recheck-rewrite' === $action ) {
398 // Qualify the raw probe against known config refusals before
399 // reporting. The probe fetches its OWN file from the static tree,
400 // which succeeds even when no real page is served that way — so
401 // an unqualified `active` reported "the web server is serving
402 // cache hits directly" on sites whose every page returned
403 // HIT (php). See Cache::qualify_rewrite_probe().
404 $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
405 $blocked = '' !== (string) $probe['block_reason'];
406
407 if ( $probe['active'] ) {
408 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
409 return;
410 }
411 if ( $blocked ) {
412 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) $probe['reason'] ) );
413 return;
414 }
415 if ( $probe['inconclusive'] ) {
416 \WP_CLI::warning( sprintf( 'Could not verify the static rewrite: %s', (string) $probe['reason'] ) );
417 \WP_CLI::log( 'This is a probe failure, not proof that your server config is wrong.' );
418 return;
419 }
420 \WP_CLI::warning( sprintf( 'Static rewrite is not active: %s', (string) ( $probe['reason'] ?: 'unknown' ) ) );
421 return;
422 }
423
424 if ( 'purge-url' === $action ) {
425 $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
426 if ( '' === $url ) {
427 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
428 return;
429 }
430 $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
431 $removed = \XSpeed\Cache::purge_url( $url, $cause );
432 if ( $removed > 0 ) {
433 \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
434 } else {
435 \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
436 }
437 return;
438 }
439
440 if ( 'inventory' === $action ) {
441 $this->cli_inventory( $limit );
442 return;
443 }
444
445 if ( 'size' === $action ) {
446 $this->cli_size();
447 return;
448 }
449
450 if ( 'purge-log' === $action ) {
451 $this->cli_purge_log( $limit );
452 return;
453 }
454
455 $opts = Settings_Manager::get( self::SLUG );
456 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
457 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
458 foreach ( $opts['excluded_urls'] as $u ) {
459 \WP_CLI::log( ' - ' . $u );
460 }
461 }
462
463 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
464 private function cli_inventory( int $limit ): void {
465 $data = \XSpeed\Cache_Inventory::entries( $limit );
466
467 if ( empty( $data['entries'] ) ) {
468 \WP_CLI::log( 'Cache is empty — no cached pages on disk.' );
469 return;
470 }
471
472 \WP_CLI::log( sprintf( '%d cached page(s); showing %d.', $data['total'], count( $data['entries'] ) ) );
473 if ( ! empty( $data['capped'] ) ) {
474 \WP_CLI::warning( sprintf( 'Scan stopped at %d files — the list is a recent sample, not the whole cache.', \XSpeed\Cache_Inventory::SCAN_CAP ) );
475 }
476 foreach ( $data['entries'] as $entry ) {
477 \WP_CLI::log(
478 sprintf(
479 ' %-58s %8s %s [%s]',
480 null === $entry['url'] ? '(url unknown: ' . $entry['key'] . ')' : $entry['url'],
481 size_format( (int) $entry['bytes'] ),
482 $this->relative_age( (int) $entry['age'] ),
483 implode( '+', (array) $entry['stored_in'] )
484 )
485 );
486 }
487 }
488
489 /** `wp xspeed cache size` — where the cache's disk usage goes. */
490 private function cli_size(): void {
491 $data = \XSpeed\Cache_Inventory::size_breakdown();
492
493 \WP_CLI::log( sprintf( 'Total %s across %d file(s).', size_format( (int) $data['total_bytes'] ), (int) $data['total_files'] ) );
494 foreach ( $data['buckets'] as $bucket ) {
495 if ( 0 === (int) $bucket['files'] ) {
496 continue;
497 }
498 \WP_CLI::log( sprintf( ' %-32s %10s %d file(s)', $bucket['label'], size_format( (int) $bucket['bytes'] ), (int) $bucket['files'] ) );
499 }
500 if ( (int) $data['compressed_bytes'] > 0 ) {
501 \WP_CLI::log( sprintf( 'Precompressed on disk: %s (pages without a precompressed copy are compressed by the web server at request time).', size_format( (int) $data['compressed_bytes'] ) ) );
502 }
503 }
504
505 /** `wp xspeed cache purge-log [--limit=N]` — what cleared the cache, when, and why. */
506 private function cli_purge_log( int $limit ): void {
507 $data = \XSpeed\Cache_Inventory::purge_log( $limit );
508
509 if ( empty( $data['events'] ) ) {
510 \WP_CLI::log( 'No purge events recorded yet.' );
511 return;
512 }
513 foreach ( $data['events'] as $event ) {
514 \WP_CLI::log( sprintf( ' %s %s', $this->relative_age( max( 0, time() - (int) $event['ts'] ) ), $event['message'] ) );
515 }
516 }
517
518 /** Compact "4h ago" for CLI columns. */
519 private function relative_age( int $seconds ): string {
520 if ( $seconds < 60 ) {
521 return $seconds . 's ago';
522 }
523 if ( $seconds < 3600 ) {
524 return (int) floor( $seconds / 60 ) . 'm ago';
525 }
526 if ( $seconds < 86400 ) {
527 return (int) floor( $seconds / 3600 ) . 'h ago';
528 }
529 return (int) floor( $seconds / 86400 ) . 'd ago';
530 }
531
532 /**
533 * Static-rewrite directives for the unified nginx server-block
534 * snippet. Returns null when cache is disabled — there's no rewrite
535 * to install in that state. Delegates to \XSpeed\Cache::nginx_snippet()
536 * which already produces nginx-detection-gated output.
537 */
538 public function nginx_directives(): ?string {
539 $opts = get_option( 'xspeed_options', array() );
540 if ( empty( $opts['cache_enabled'] ) ) {
541 return null;
542 }
543 return \XSpeed\Cache::nginx_snippet();
544 }
545 }
546