PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/modules/Cache/CacheModule.php +250 -20 1.3.7 → 1.4.2 View file →
@@ -225,9 +225,10 @@
225 225 '/wc-api',
226 226 '/edd-api',
227 227 '/wp-login',
228 228 ),
229 - 'item_type' => 'string',
229 + // `path`: kept as typed, percent-encoded slugs included.
230 + 'item_type' => 'path',
230 231 'label' => __( 'Excluded URLs', 'xspeed' ),
231 232 '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' ),
232 233 ),
233 234 'excluded_cookies' => array(
@@ -265,8 +266,16 @@
265 266 'default' => true,
266 267 'label' => __( 'Clear cache after updates', 'xspeed' ),
267 268 'description' => __( 'Clear the page cache when a plugin, theme or WordPress is updated, so visitors never get old pages with broken asset links.', 'xspeed' ),
268 269 ),
270 + 'purge_affected_only' => array(
271 + 'type' => 'bool',
272 + 'default' => true,
273 + 'label' => __( 'Clear only the pages a change affects', 'xspeed' ),
274 + 'description' => __( 'When you publish, edit or delete a post, clear that post, the pages that list it and its feeds instead of the whole site. Pages with a post grid from a page builder, a block plugin or the theme are noted as they are built and cleared when the change can affect their list; turn this off if a grid still shows an old list.', 'xspeed' ),
275 + 'info_title' => __( 'Which pages are cleared', 'xspeed' ),
276 + 'info' => __( 'The post, its old address if it moved, the home page, the blog page, its categories, tags and author pages with every page of each, the date archives, feeds, the posts next to it, pages that show a Latest Posts or Query Loop block, directly or in a synced pattern, and pages noted running a post grid of their own (a page builder, block plugin, related-posts or theme list) that the change can affect. If your theme shows a list that can be on any page, and the change alters it, xSpeed clears the whole site instead: a Recent Posts, Archives or Calendar widget, a post list block, or a latest-posts grid from a page builder or plugin (usually a new post, or an edit to one of the newest few it shows), a page list or menu (a page published, withdrawn, renamed or moved), or a Categories or Tag Cloud list (a post moved to other categories or tags).', 'xspeed' ),
277 + ),
269 278 'mobile_separate' => array(
270 279 'type' => 'bool',
271 280 'default' => false,
272 281 'label' => __( 'Separate mobile cache', 'xspeed' ),
@@ -277,9 +286,9 @@
277 286 'default' => 'auto',
278 287 'options' => array( 'auto', 'off', 'cloudflare', 'fastly', 'varnish', 'nginx', 'akamai', 'cloudfront', 'google', 'keycdn', 'bunny', 'sucuri', 'incapsula', 'generic', 'custom' ),
279 288 'option_labels' => array(
280 289 'auto' => 'Detect automatically',
281 - 'off' => 'Off — send nothing',
290 + 'off' => 'Off (no edge lifetime or cache tags)',
282 291 'cloudflare' => 'Cloudflare',
283 292 'varnish' => 'Varnish',
284 293 'nginx' => 'nginx proxy cache',
285 294 'cloudfront' => 'Amazon CloudFront',
@@ -305,9 +314,9 @@
305 314 'label' => __( 'CDN or proxy in front', 'xspeed' ),
306 315 '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 316 'advanced' => true,
308 317 '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' )
318 + '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. Off stops the edge lifetime and cache tags. When an add-on that manages the edge, such as a Cloudflare Enterprise add-on, is active, the "don\'t store" headers for logged-in, cart and search pages still go out. Run "wp xspeed cache edge" to see what was detected and what gets sent.', 'xspeed' )
310 319 ),
311 320 'edge_custom_headers' => array(
312 321 'type' => 'list',
313 322 'default' => array(),
@@ -514,9 +523,9 @@
514 523 'synopsis' => array(
515 524 array(
516 525 'type' => 'assoc',
517 526 'name' => 'type',
518 - 'description' => 'What to clear: all (default), page, object, cloudflare, cdn — or a group name (edge). Comma-separate to clear several.',
527 + 'description' => 'What to clear: all (default), page, object, cloudflare, xcloud (sites with xCloud\'s purge plugin), cdn — or a group name (edge). Comma-separate to clear several.',
519 528 'optional' => true,
520 529 ),
521 530 array(
522 531 'type' => 'assoc',
@@ -535,35 +544,49 @@
535 544 ),
536 545 array(
537 546 'name' => 'xspeed cache',
538 547 'callback' => array( $this, 'cli_handler' ),
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`.',
548 + '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` to clear everything (same as `wp xspeed purge`), `purge <url>` or `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, or `listings` to show which pages were recorded running a post list of their own (cleared by a narrow purge when a save may change that list). To clear the whole site use `wp xspeed purge`.',
540 549 'synopsis' => array(
541 550 array(
542 551 'type' => 'positional',
543 552 'name' => 'action',
544 - 'options' => array( 'status', 'inventory', 'size', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge' ),
553 + 'options' => array( 'status', 'inventory', 'size', 'purge', 'purge-log', 'purge-url', 'recheck-rewrite', 'nginx-config', 'edge', 'listings' ),
545 554 'optional' => true,
546 555 ),
547 556 array(
548 - 'type' => 'positional',
549 - 'name' => 'url',
550 - 'optional' => true,
557 + 'type' => 'positional',
558 + 'name' => 'url',
559 + 'description' => 'For purge and purge-url: the page to clear, as a URL on this site or a path such as /about/. Without it, purge clears everything.',
560 + 'optional' => true,
551 561 ),
552 562 array(
553 563 'type' => 'assoc',
554 564 'name' => 'limit',
555 - 'description' => 'Rows to print for inventory / purge-log. Default 20.',
565 + 'description' => 'Rows to print for inventory / purge-log (default 20), or sample pages for listings (default 5).',
556 566 'optional' => true,
557 567 ),
558 568 array(
559 569 'type' => 'assoc',
560 570 'name' => 'cause',
561 - 'description' => 'Label recorded in the purge log for purge-url. Default "CLI".',
571 + 'description' => 'Label recorded in the purge log for purge and purge-url. Default "CLI".',
562 572 'optional' => true,
563 573 ),
564 574 array(
565 575 'type' => 'assoc',
576 + 'name' => 'type',
577 + 'description' => 'For purge without a URL: what to clear, as in `wp xspeed purge --type`. Default all.',
578 + 'optional' => true,
579 + ),
580 + array(
581 + 'type' => 'assoc',
582 + 'name' => 'format',
583 + 'description' => 'For purge without a URL: table (default) or json.',
584 + 'optional' => true,
585 + 'options' => array( 'table', 'json' ),
586 + ),
587 + array(
588 + 'type' => 'assoc',
566 589 'name' => 'server',
567 590 'description' => 'Server type to assume for nginx-config, skipping detection. Detection needs SERVER_SOFTWARE, which the command line does not have; an undetectable host is assumed to be nginx anyway, so this is for stating it outright — or for the case detection is positively wrong, such as nginx in front of Apache.',
568 591 'options' => array( 'nginx', 'apache', 'litespeed' ),
569 592 'optional' => true,
@@ -715,9 +738,16 @@
715 738 foreach ( $report['types'] as $row ) {
716 739 switch ( $row['status'] ) {
717 740 case \XSpeed\Purge_Runner::CLEARED:
718 741 ++$cleared;
719 - \WP_CLI::log( sprintf( ' cleared %s%s', $row['label'], self::purge_amount( $row ) ) );
742 + \WP_CLI::log(
743 + sprintf(
744 + ' cleared %s%s%s',
745 + $row['label'],
746 + self::purge_amount( $row ),
747 + '' !== $row['reason'] ? '; ' . $row['reason'] : ''
748 + )
749 + );
720 750 break;
721 751 case \XSpeed\Purge_Runner::FAILED:
722 752 ++$failed;
723 753 \WP_CLI::log( sprintf( ' FAILED %s — %s', $row['label'], $row['reason'] ) );
@@ -751,8 +781,65 @@
751 781 \WP_CLI::success( sprintf( 'Purged %d cache store(s); %d skipped.', $cleared, $skipped ) );
752 782 }
753 783
754 784 /**
785 + * Clear one page: `wp xspeed cache purge-url <url>` and
786 + * `wp xspeed cache purge <url>`.
787 + *
788 + * A string purge_url() cannot act on (another scheme, a scheme with no
789 + * host, a bare word) is refused with an error. purge_url() returns 0 for
790 + * it, which read as "already cold" and exited 0.
791 + *
792 + * @param string $url Absolute URL or site-relative path.
793 + * @param array<string,string> $assoc Flags; only --cause is read.
794 + */
795 + private function cli_purge_url( string $url, array $assoc ): void {
796 + if ( ! self::is_purgeable_url( $url ) ) {
797 + \WP_CLI::error( sprintf( 'Not a page URL: %s. Give an http(s) URL on this site or a path starting with /, such as /about/.', $url ) );
798 + return;
799 + }
800 + $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
801 + $result = \XSpeed\Cache::purge_url_reported( $url, $cause );
802 + $removed = $result['removed'];
803 + $forwarded = implode( ', ', $result['forwarded'] );
804 + if ( $removed > 0 ) {
805 + \WP_CLI::success(
806 + '' === $forwarded
807 + ? sprintf( 'Purged %d cache file(s) for %s', $removed, $url )
808 + : sprintf( 'Purged %d cache file(s) for %s, and sent the purge to %s', $removed, $url, $forwarded )
809 + );
810 + } elseif ( '' !== $forwarded ) {
811 + // xSpeed's own cache held nothing, but a cache in front of
812 + // PHP took the purge: that copy is the one visitors get.
813 + \WP_CLI::success( sprintf( 'Sent the purge for %s to %s. xSpeed\'s own cache held no copy.', $url, $forwarded ) );
814 + } else {
815 + \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
816 + }
817 + }
818 +
819 + /**
820 + * Whether purge_url() can act on this string: an http(s) URL with a
821 + * host, a host without a scheme (`site.test/page/`), or a path starting
822 + * with `/`. The same rules purge_url() applies before it returns 0.
823 + *
824 + * @param string $url What the operator typed.
825 + */
826 + private static function is_purgeable_url( string $url ): bool {
827 + $parts = wp_parse_url( $url );
828 + if ( ! is_array( $parts ) ) {
829 + return false;
830 + }
831 + if ( isset( $parts['scheme'] ) ) {
832 + return in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) && ! empty( $parts['host'] );
833 + }
834 + if ( isset( $parts['host'] ) ) {
835 + return true;
836 + }
837 + $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
838 + return '' !== $path && '/' === $path[0];
839 + }
840 +
841 + /**
755 842 * The " — 42 entries (1.3 MB)" tail on a cleared line.
756 843 *
757 844 * Entries and bytes are both optional: a Redis FLUSHALL reports neither,
758 845 * and printing "0 entries" for it would read as an empty cache rather
@@ -775,8 +862,29 @@
775 862 public function cli_handler( array $args, array $assoc ): void {
776 863 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
777 864 $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
778 865
866 + // `wp xspeed cache purge` is what people type. It runs the same
867 + // purge as `wp xspeed purge`; before it was listed, WP-CLI rejected
868 + // it with "Invalid value specified for positional arg". Given a URL
869 + // it clears that one page, as purge-url does: it used to drop the
870 + // URL and clear the whole site.
871 + if ( 'purge' === $action ) {
872 + $url = isset( $args[1] ) ? trim( (string) $args[1] ) : '';
873 + if ( '' === $url ) {
874 + $this->cli_purge( array(), $assoc );
875 + return;
876 + }
877 + foreach ( array( 'type', 'format' ) as $flag ) {
878 + if ( isset( $assoc[ $flag ] ) ) {
879 + \WP_CLI::error( sprintf( '--%s applies to a full purge, not to one URL. Run `wp xspeed cache purge %s` without it, or drop the URL to purge everything.', $flag, $url ) );
880 + return;
881 + }
882 + }
883 + $this->cli_purge_url( $url, $assoc );
884 + return;
885 + }
886 +
779 887 /*
780 888 * Print the unified nginx server-block so an installer, provisioning
781 889 * script, or another plugin can fetch it non-interactively and write
782 890 * it into a vhost. Previously this was only reachable via
@@ -903,8 +1011,15 @@
903 1011 // HIT (php). See Cache::qualify_rewrite_probe().
904 1012 $probe = \XSpeed\Cache::qualify_rewrite_probe( \XSpeed\Cache::recheck_static_rewrite() );
905 1013 $blocked = '' !== (string) $probe['block_reason'];
906 1014
1015 + // Whether the installed rules are the ones these settings
1016 + // generate. Reported before the verdict below because it is the
1017 + // question someone running this command has just acted on — they
1018 + // pasted a block and want to know if it took — and because
1019 + // "active" is true for a stale block too.
1020 + $this->cli_report_rules_state( (array) ( $probe['rules'] ?? array() ) );
1021 +
907 1022 if ( $probe['active'] ) {
908 1023 \WP_CLI::success( 'Static rewrite is active — the web server is serving cache hits directly.' );
909 1024 return;
910 1025 }
@@ -926,15 +1041,9 @@
926 1041 if ( '' === $url ) {
927 1042 \WP_CLI::error( 'Usage: wp xspeed cache purge-url <url-or-path>' );
928 1043 return;
929 1044 }
930 - $cause = isset( $assoc['cause'] ) && '' !== trim( (string) $assoc['cause'] ) ? trim( (string) $assoc['cause'] ) : 'CLI';
931 - $removed = \XSpeed\Cache::purge_url( $url, $cause );
932 - if ( $removed > 0 ) {
933 - \WP_CLI::success( sprintf( 'Purged %d cache file(s) for %s', $removed, $url ) );
934 - } else {
935 - \WP_CLI::log( sprintf( 'No cache entries found for %s (already cold, or the URL never cached).', $url ) );
936 - }
1045 + $this->cli_purge_url( $url, $assoc );
937 1046 return;
938 1047 }
939 1048
940 1049 if ( 'inventory' === $action ) {
@@ -956,8 +1065,13 @@
956 1065 $this->cli_edge();
957 1066 return;
958 1067 }
959 1068
1069 + if ( 'listings' === $action ) {
1070 + $this->cli_listings( isset( $assoc['limit'] ) ? $limit : 5 );
1071 + return;
1072 + }
1073 +
960 1074 $opts = Settings_Manager::get( self::SLUG );
961 1075 \WP_CLI::log( 'cache_expiry ' . $opts['cache_expiry'] . 'h' );
962 1076 \WP_CLI::log( 'excluded_urls ' . count( $opts['excluded_urls'] ) . ' entries' );
963 1077 foreach ( $opts['excluded_urls'] as $u ) {
@@ -1036,15 +1150,131 @@
1036 1150 \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1037 1151 }
1038 1152 }
1039 1153
1154 + // The same bake the drop-in gets, so it asks no per-page question.
1155 + $variant = \XSpeed\Cache::query_variant_edge_headers();
1156 +
1040 1157 \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.' );
1158 + if ( array() === $variant ) {
1159 + \WP_CLI::log( 'A cached page requested with an ignored parameter (?utm_source=x) gets the same headers as without one. It is held only where a cache in front was detected, and with Separate Mobile Cache on every page is held already.' );
1160 + } else {
1161 + \WP_CLI::log( 'On a cached page requested with an ignored parameter (?utm_source=x), a cached search or a query-form feed:' );
1162 + foreach ( $variant as $name => $value ) {
1163 + \WP_CLI::log( sprintf( ' %s: %s', $name, $value ) );
1164 + }
1165 + }
1042 1166
1167 + \WP_CLI::log( '' );
1168 + \WP_CLI::log( 'X-XSpeed-Edge-Hold names why a response was held: bypass, bypass-shape, miss, mobile-split, pending or query-variant. No header means nothing was held.' );
1169 +
1043 1170 if ( 'cloudflare' === $answer['provider'] ) {
1044 1171 \WP_CLI::log( '' );
1045 1172 \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.' );
1046 1173 }
1174 + }
1175 +
1176 + /**
1177 + * `wp xspeed cache listings`: the pages recorded running a post list
1178 + * outside the main loop, which a narrow purge adds when a save may
1179 + * change that list.
1180 + *
1181 + * For measuring how wide that set gets on a real site: close to every
1182 + * page means a narrow purge saves little there.
1183 + *
1184 + * @param int $samples Pages to print.
1185 + */
1186 + private function cli_listings( int $samples ): void {
1187 + $narrow = ! empty( Settings_Manager::get( self::SLUG )['purge_affected_only'] );
1188 + $stats = \XSpeed\Listing_Pages::stats( $samples );
1189 + $limit = \XSpeed\Affected_Pages::LIMIT;
1190 +
1191 + \WP_CLI::log( 'narrow purge ' . ( $narrow ? 'on' : 'off' ) );
1192 + \WP_CLI::log( 'recording ' . ( \XSpeed\Listing_Pages::enabled() ? 'on' : 'off' ) );
1193 + \WP_CLI::log( 'directory ' . $stats['dir'] );
1194 +
1195 + \WP_CLI::log( '' );
1196 + \WP_CLI::log( 'Newest-N lists (' . count( $stats['specs'] ) . ' of ' . \XSpeed\Listing_Pages::SPEC_CAP . '): a change to one of the newest N clears the whole site.' );
1197 + foreach ( $stats['specs'] as $spec ) {
1198 + \WP_CLI::log( sprintf( ' newest %d of %s%s', $spec['n'], implode( ', ', $spec['types'] ), $spec['sticky'] ? ', sticky posts first' : '' ) );
1199 + }
1200 +
1201 + \WP_CLI::log( '' );
1202 + \WP_CLI::log( sprintf( 'Pages with other lists: %d recorded (%d files, cap %d); %d posts in the shown-on index.', $stats['pages'], $stats['files'], \XSpeed\Listing_Pages::CAP, $stats['posts'] ) );
1203 + foreach ( $stats['types'] as $type => $counts ) {
1204 + \WP_CLI::log(
1205 + sprintf(
1206 + ' %-14s %d pages, %d not precise%s',
1207 + $type,
1208 + $counts['all'],
1209 + $counts['loose'],
1210 + $counts['loose'] > $limit
1211 + ? ' (over ' . $limit . ' not precise: any change to a post of this type clears the whole site)'
1212 + : ( $counts['all'] > $limit ? ' (over ' . $limit . ': a change that is not a plain edit clears the whole site)' : '' )
1213 + )
1214 + );
1215 + }
1216 +
1217 + if ( null !== $stats['full'] ) {
1218 + \WP_CLI::warning(
1219 + sprintf(
1220 + 'The record is full, and pages listing %s went unrecorded: a change to a post of those types clears the whole site until a save finds the record under the cap.',
1221 + implode( ', ', array_map( 'strval', (array) ( $stats['full']['types'] ?? array( 'any' ) ) ) )
1222 + )
1223 + );
1224 + }
1225 + if ( array() === $stats['samples'] ) {
1226 + return;
1227 + }
1228 + \WP_CLI::log( '' );
1229 + foreach ( $stats['samples'] as $record ) {
1230 + \WP_CLI::log(
1231 + sprintf(
1232 + ' %s [%s] %s',
1233 + $record['url'],
1234 + implode( ', ', $record['types'] ),
1235 + $record['precise'] ? 'precise, ' . count( $record['ids'] ) . ' post(s) shown' : 'not precise'
1236 + )
1237 + );
1238 + }
1239 + }
1240 +
1241 + /**
1242 + * Say which version of the cache rules the server is running.
1243 + *
1244 + * The generated block stamps every static hit with a hash of itself, and
1245 + * the probe reads that back — the only way to tell what someone actually
1246 + * pasted into a config WordPress cannot open. `stale` names the settings
1247 + * that moved since, where we can tell, because "re-paste the block" with
1248 + * no reason attached is what makes people ignore it.
1249 + *
1250 + * @param array $rules Cache::rules_state() output.
1251 + */
1252 + private function cli_report_rules_state( array $rules ): void {
1253 + $state = (string) ( $rules['state'] ?? '' );
1254 +
1255 + if ( 'current' === $state ) {
1256 + \WP_CLI::log( 'Cache rules: current — the installed rules are the ones these settings generate.' );
1257 + return;
1258 + }
1259 + if ( 'stale' === $state ) {
1260 + $changed = array_filter( array_map( 'strval', (array) ( $rules['changed'] ?? array() ) ) );
1261 + \WP_CLI::log(
1262 + '' === implode( '', $changed )
1263 + ? 'Cache rules: stale — a different version of the rules is installed. Re-paste the block.'
1264 + : sprintf(
1265 + 'Cache rules: stale — a different version of the rules is installed (%s changed since). Re-paste the block.',
1266 + implode( ', ', $changed )
1267 + )
1268 + );
1269 + return;
1270 + }
1271 + if ( 'absent' === $state ) {
1272 + \WP_CLI::log( 'Cache rules: absent — no xSpeed rules are installed, or they predate the version marker.' );
1273 + return;
1274 + }
1275 +
1276 + \WP_CLI::log( 'Cache rules: unknown — the probe could not read a rules marker back from this server.' );
1047 1277 }
1048 1278
1049 1279 /** `wp xspeed cache inventory [--limit=N]` — which pages are cached, and how old. */
1050 1280 private function cli_inventory( int $limit ): void {