PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.8 All 29 releases
← All changes | includes/class-server.php +341 -48 1.1.31.3.3 View file →
@@ -21,8 +21,16 @@
21 21 const UNKNOWN = 'unknown';
22 22
23 23 const OPT_CACHED_TYPE = 'xspeed_server_type';
24 24
25 + /**
26 + * Last authoritative mod_headers answer, captured under mod_php where
27 + * apache_get_modules() actually exists. Read by SAPIs that cannot
28 + * detect (WP-CLI, FPM) so one host gives one answer. See
29 + * apache_has_mod_headers().
30 + */
31 + const OPT_CACHED_MOD_HEADERS = 'xspeed_apache_mod_headers';
32 +
25 33 public static function type() {
26 34 $detected = self::detect();
27 35 if ( self::UNKNOWN !== $detected ) {
28 36 // Persist whenever we have a real answer so future CLI /
@@ -31,20 +39,36 @@
31 39 $cached = get_option( self::OPT_CACHED_TYPE, null );
32 40 if ( $cached !== $detected ) {
33 41 update_option( self::OPT_CACHED_TYPE, $detected, false );
34 42 }
35 - return $detected;
43 + $type = $detected;
44 + } else {
45 + // No definitive signal this request (typically WP-CLI, where
46 + // SERVER_SOFTWARE is empty). Read whatever was cached the last
47 + // time we ran from a real HTTP request.
48 + $cached = get_option( self::OPT_CACHED_TYPE, null );
49 + $type = ( is_string( $cached ) && '' !== $cached ) ? $cached : self::UNKNOWN;
36 50 }
37 51
38 - // No definitive signal this request (typically WP-CLI, where
39 - // SERVER_SOFTWARE is empty). Read whatever was cached the last
40 - // time we ran from a real HTTP request.
41 - $cached = get_option( self::OPT_CACHED_TYPE, null );
42 - if ( is_string( $cached ) && '' !== $cached ) {
43 - return $cached;
44 - }
45 -
46 - return self::UNKNOWN;
52 + /**
53 + * Filters the resolved server type.
54 + *
55 + * The override point for contexts that cannot detect. Detection
56 + * reads SERVER_SOFTWARE, which the web server supplies and WP-CLI
57 + * therefore never has; the cached option covers CLI runs on a site
58 + * some request has already reached, but a site provisioned entirely
59 + * over WP-CLI has nothing cached and resolves to `unknown` even on
60 + * nginx. `wp xspeed cache nginx-config --server=` hooks this so
61 + * every gate downstream — Cache::nginx_snippet(), each module's own
62 + * nginx_directives() — agrees on one answer, rather than each
63 + * re-deciding and emitting a half-built config.
64 + *
65 + * Filtering does NOT write the cached option: an assumption stated
66 + * for one command must not become this site's persisted answer.
67 + *
68 + * @param string $type One of apache|litespeed|nginx|iis|unknown.
69 + */
70 + return apply_filters( 'xspeed_server_type', $type );
47 71 }
48 72
49 73 /**
50 74 * Live detection — never reads the cache. Used by type() and by
@@ -102,26 +126,47 @@
102 126 * the user, nothing for the hit counter. That is exactly the
103 127 * "cache works, dashboard says 0%" report this check exists to
104 128 * prevent. (Cache::static_rewrite_allowed() consumes it.)
105 129 *
106 - * Detection is best-effort by necessity:
107 - * - mod_php exposes apache_get_modules() — authoritative.
108 - * - Under PHP-FPM that function doesn't exist. Assume the module
109 - * IS present, matching Apache's own default build (mod_headers
110 - * ships enabled in every mainstream distro package). Guessing
111 - * "absent" there would push every FPM site onto the slower
112 - * drop-in path over a detection limitation rather than a real
113 - * capability gap; the loopback probe in
130 + * Detection is best-effort by necessity, and MUST NOT vary by SAPI:
131 + * - mod_php exposes apache_get_modules() — authoritative. Persist
132 + * that answer so other SAPIs can inherit it.
133 + * - Under PHP-FPM / WP-CLI the function doesn't exist. Read the
134 + * stored mod_php answer; only when nothing was ever stored do we
135 + * assume the module IS present, matching Apache's own default
136 + * build (mod_headers ships enabled in every mainstream distro
137 + * package). Guessing "absent" there would push every FPM site
138 + * onto the slower drop-in path over a detection limitation
139 + * rather than a real capability gap; the loopback probe in
114 140 * Cache::probe_static_rewrite() is what catches a genuinely
115 141 * header-less FPM host.
116 142 *
143 + * Returning a different answer per SAPI is not merely inaccurate: it
144 + * makes static_rewrite_allowed() disagree with the on-disk .htaccess,
145 + * so every WP-CLI bootstrap "corrects" what the last web request
146 + * wrote and vice versa — an endless rewrite/purge ping-pong that
147 + * keeps the hit ratio pinned near zero. (#138)
148 + *
117 149 * @return bool
118 150 */
119 151 public static function apache_has_mod_headers(): bool {
120 152 if ( function_exists( 'apache_get_modules' ) ) {
121 153 $has = in_array( 'mod_headers', apache_get_modules(), true );
154 +
155 + // Authoritative — persist so CLI/FPM inherit it instead of
156 + // guessing. Non-autoloaded; only read when needed.
157 + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null );
158 + $want = $has ? '1' : '0';
159 + if ( (string) $cached !== $want ) {
160 + update_option( self::OPT_CACHED_MOD_HEADERS, $want, false );
161 + }
122 162 } else {
123 - $has = true; // FPM: undetectable, assume the distro default.
163 + // Cannot detect here. Prefer the last known real answer over
164 + // an optimistic guess that would flip static_rewrite_allowed().
165 + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null );
166 + $has = ( null === $cached || '' === $cached )
167 + ? true // never detected: assume the distro default.
168 + : (bool) (int) $cached;
124 169 }
125 170
126 171 /**
127 172 * Filter: xspeed_apache_has_mod_headers
@@ -301,8 +346,19 @@
301 346 } elseif ( function_exists( 'apache_get_modules' ) && in_array( 'mod_brotli', apache_get_modules(), true ) ) {
302 347 $available = true;
303 348 } elseif ( function_exists( 'brotli_compress' ) ) {
304 349 $available = true;
350 + } elseif ( self::NGINX === self::type() ) {
351 + // nginx modules are not introspectable from PHP, so none of the
352 + // branches above can ever be true on the very common nginx +
353 + // php-fpm setup — even while ngx_brotli is actively serving
354 + // `Content-Encoding: br` on every request. Reporting "unavailable"
355 + // there told users who had done everything right to go install a
356 + // module they already had.
357 + //
358 + // So ask the server instead of asking PHP: one cached loopback
359 + // request with `Accept-Encoding: br`, and read what comes back.
360 + $available = self::brotli_probe();
305 361 }
306 362
307 363 /**
308 364 * Filter detected Brotli availability.
@@ -312,8 +368,205 @@
312 368 return (bool) apply_filters( 'xspeed_brotli_available', $available );
313 369 }
314 370
315 371 /**
372 + * Ask the web server whether it serves Brotli, by requesting our own home
373 + * URL with `Accept-Encoding: br` and reading the response encoding.
374 + *
375 + * The only way to answer this on nginx: the module list isn't visible to
376 + * PHP, so introspection can't work and the request itself is the evidence.
377 + *
378 + * Cached in a transient — a positive result for a day (server modules
379 + * don't come and go), a negative for an hour so someone who has just
380 + * installed ngx_brotli isn't told "no" until tomorrow. Failures cache
381 + * briefly too, so a host that hangs on loopback self-requests can't turn
382 + * every dashboard load into a timeout.
383 + *
384 + * @param bool $force Skip the cache and re-probe.
385 + */
386 + public static function brotli_probe( bool $force = false ): bool {
387 + return 'yes' === self::brotli_probe_state( $force );
388 + }
389 +
390 + /**
391 + * The probe's three-way answer: 'yes', 'no', or 'unknown'.
392 + *
393 + * brotli_probe() collapses this to a bool because every consumer wants
394 + * one, but the distinction matters for what we TELL the user.
395 + * "unknown" — a blocked or failing loopback — is not evidence that the
396 + * server lacks Brotli, and reporting it as "no" would repeat the original
397 + * bug in a new place: telling someone whose setup is fine that it isn't.
398 + *
399 + * @param bool $force Skip the cache and re-probe.
400 + * @return string 'yes' | 'no' | 'unknown'
401 + */
402 + public static function brotli_probe_state( bool $force = false ): string {
403 + $key = 'xspeed_brotli_probe';
404 +
405 + if ( ! $force ) {
406 + $cached = get_transient( $key );
407 + if ( false !== $cached ) {
408 + $cached = (string) $cached;
409 + // Legacy '1'/'0' values from an earlier cache format.
410 + if ( '1' === $cached ) {
411 + return 'yes';
412 + }
413 + if ( '0' === $cached ) {
414 + return 'no';
415 + }
416 + return in_array( $cached, array( 'yes', 'no', 'unknown' ), true ) ? $cached : 'unknown';
417 + }
418 + }
419 +
420 + $url = home_url( '/' );
421 + if ( ! function_exists( 'wp_remote_get' ) || '' === $url ) {
422 + return 'unknown';
423 + }
424 +
425 + // Stampede guard. On a cold transient every concurrent dashboard load
426 + // would otherwise fire its own 3s loopback request, because nothing
427 + // was written until the response came back. Claim the slot BEFORE the
428 + // request so the other callers answer 'unknown' (accurate — they
429 + // genuinely don't know yet) rather than piling on.
430 + $inflight = $key . '_inflight';
431 + if ( ! $force && false !== get_transient( $inflight ) ) {
432 + return 'unknown';
433 + }
434 + set_transient( $inflight, 1, 30 );
435 +
436 + // Mirror Cache::probe_static_rewrite()'s posture: short timeout so a
437 + // blocked loopback can't stall the caller, and relax cert verification
438 + // only in local/dev where self-signed certs are normal.
439 + $is_local = function_exists( 'wp_get_environment_type' )
440 + && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
441 +
442 + $resp = wp_remote_get(
443 + $url,
444 + array(
445 + 'timeout' => 3,
446 + 'sslverify' => ! $is_local,
447 + 'redirection' => 0,
448 + 'headers' => array(
449 + // `br` ONLY. Offering gzip as well would let a server that
450 + // prefers gzip answer with it and look like a brotli
451 + // failure, which is exactly the false negative this method
452 + // exists to remove.
453 + 'Accept-Encoding' => 'br',
454 + 'Cache-Control' => 'no-cache',
455 + ),
456 + )
457 + );
458 +
459 + delete_transient( $inflight );
460 +
461 + if ( is_wp_error( $resp ) ) {
462 + // Can't reach ourselves. This is NOT evidence the server lacks
463 + // Brotli — reporting it as "no" would repeat the original bug in a
464 + // new place. Cache briefly so a hanging host doesn't cost 3s on
465 + // every call, but re-check soon.
466 + set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
467 + return 'unknown';
468 + }
469 +
470 + // A non-2xx answer tells us nothing about compression: basic auth
471 + // (401), maintenance mode (503) and WAF challenge pages are all
472 + // "couldn't check", not "no module". Caching 'no' for an hour on the
473 + // strength of one is the same category error this method fixes.
474 + $code = (int) wp_remote_retrieve_response_code( $resp );
475 + if ( $code < 200 || $code >= 300 ) {
476 + set_transient( $key, 'unknown', 5 * MINUTE_IN_SECONDS );
477 + return 'unknown';
478 + }
479 +
480 + // A CDN or reverse proxy in front of the origin compresses on its own
481 + // behalf, so `content-encoding: br` would describe the EDGE, not this
482 + // server. On Apache/LiteSpeed that is harmless (brotli_available()
483 + // short-circuits before consulting the probe), but on nginx the probe
484 + // IS the answer — and a large share of nginx sites sit behind
485 + // Cloudflare, Fastly or a load balancer. Asserting 'yes' there is the
486 + // mirror image of the false negative this method exists to remove, so
487 + // we answer 'unknown': we genuinely could not observe the origin.
488 + if ( self::response_came_through_proxy( $resp ) ) {
489 + set_transient( $key, 'unknown', HOUR_IN_SECONDS );
490 + return 'unknown';
491 + }
492 +
493 + $encoding = wp_remote_retrieve_header( $resp, 'content-encoding' );
494 + if ( is_array( $encoding ) ) {
495 + $encoding = implode( ',', $encoding );
496 + }
497 + $serves_brotli = false !== stripos( (string) $encoding, 'br' );
498 +
499 + // A positive is durable (server modules don't come and go); a negative
500 + // expires sooner so someone who has just installed ngx_brotli isn't
501 + // told "no" until tomorrow.
502 + $state = $serves_brotli ? 'yes' : 'no';
503 + set_transient( $key, $state, $serves_brotli ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
504 +
505 + return $state;
506 + }
507 +
508 + /**
509 + * Did this response come back through a CDN / reverse proxy rather than
510 + * straight from our own web server?
511 + *
512 + * home_url() resolves through public DNS, so the request can leave the
513 + * box entirely and be answered at an edge. These headers are the evidence
514 + * the edge leaves behind; none of them are set by a plain origin.
515 + *
516 + * Deliberately conservative — a false "there's a proxy" costs a user the
517 + * capability assertion and shows the 'unknown' copy, while a false "no
518 + * proxy" tells an nginx user Brotli is on when their origin cannot serve
519 + * it. Cache::probe_static_rewrite() shares this blind spot, which is why
520 + * this is a public helper rather than inline.
521 + *
522 + * @param array|\WP_Error $resp Response from wp_remote_get().
523 + */
524 + public static function response_came_through_proxy( $resp ): bool {
525 + if ( is_wp_error( $resp ) ) {
526 + return false;
527 + }
528 +
529 + // Headers whose mere presence means an intermediary handled this.
530 + foreach ( array( 'cf-ray', 'x-served-by', 'x-cache', 'via', 'x-varnish', 'fastly-io-info', 'x-amz-cf-id', 'x-akamai-transformed', 'x-sucuri-id' ) as $header ) {
531 + $value = wp_remote_retrieve_header( $resp, $header );
532 + if ( is_array( $value ) ) {
533 + $value = implode( ',', $value );
534 + }
535 + if ( '' !== (string) $value ) {
536 + return true;
537 + }
538 + }
539 +
540 + // `server:` naming a known edge. Checked by substring because these
541 + // arrive as `cloudflare`, `Sucuri/Cloudproxy`, `AkamaiGHost`, etc.
542 + $server = wp_remote_retrieve_header( $resp, 'server' );
543 + if ( is_array( $server ) ) {
544 + $server = implode( ',', $server );
545 + }
546 + $server = strtolower( (string) $server );
547 + foreach ( array( 'cloudflare', 'cloudfront', 'akamai', 'fastly', 'sucuri', 'incapsula', 'stackpath', 'bunnycdn', 'keycdn' ) as $needle ) {
548 + if ( false !== strpos( $server, $needle ) ) {
549 + return true;
550 + }
551 + }
552 +
553 + return (bool) apply_filters( 'xspeed_response_came_through_proxy', false, $resp );
554 + }
555 +
556 + /**
557 + * Drop the cached Brotli probe result so the next call re-checks.
558 + *
559 + * Without this a user who installs ngx_brotli has no way to make the
560 + * dashboard notice before the transient expires — the same gap
561 + * Cache::recheck_static_rewrite() exists to close.
562 + */
563 + public static function recheck_brotli(): bool {
564 + delete_transient( 'xspeed_brotli_probe' );
565 + return self::brotli_probe( true );
566 + }
567 +
568 + /**
316 569 * Is WordPress running inside a container (Docker / Podman / k8s)?
317 570 *
318 571 * Three signals checked in cheapness order, OR'd together:
319 572 * 1. /.dockerenv exists — Docker's traditional marker; rare absence.
@@ -419,18 +672,34 @@
419 672 : '';
420 673 }
421 674
422 675 /**
423 - * Detect active caching plugins that would conflict with xSpeed. Returns
424 - * a list of human-readable labels for any conflicting plugin currently
425 - * active; empty array means the field is clear. Used by the onboarding
426 - * wizard's Step 1 health check and (Phase 2.1) the main dashboard's
427 - * Health card.
676 + * Active PAGE-CACHING plugins that would fight xSpeed over the cache
677 + * drop-in. Returns human-readable labels; an empty array means the field
678 + * is clear. Used by the onboarding wizard's Step 1 health check and the
679 + * dashboard's Health card, both of which tell the user to deactivate what
680 + * is listed "to avoid double-caching".
428 681 *
429 - * The detection key is the plugin's main file path relative to the
430 - * plugins directory — the same value WordPress uses internally in
431 - * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`)
432 - * would false-positive on disabled plugins still on disk.
682 + * Which is why the list is filtered on the page-cache capability rather
683 + * than "is it a performance plugin": Autoptimize only minifies, so naming
684 + * it here made the health row give advice that was flatly wrong.
685 + * Minification overlap is still caught — by Conflict_Registry, per feature.
686 + *
687 + * The detection key is the plugin's main file path relative to the plugins
688 + * directory — the same value WordPress uses internally in `active_plugins`.
689 + * Folder-only checks (`is_plugin_active('foo/')`) would false-positive on
690 + * disabled plugins still on disk.
691 + *
692 + * Membership comes from Cache_Plugin_Catalog, so a plugin is added in one
693 + * place and shows up in both this list and the conflict matrix.
694 + *
695 + * Activation is not the whole test, though. What actually stops xSpeed
696 + * enabling its cache is who holds advanced-cache.php, and a drop-in left
697 + * behind by an uninstalled plugin holds it just as firmly as a running
698 + * one. Checking only active_plugins let the wizard say "No other caching
699 + * plugins detected" on the environment step and then refuse the enable on
700 + * the very next step, for a file it had just looked past. So a foreign
701 + * drop-in is listed too, named where we can name it.
433 702 */
434 703 public static function conflicts() {
435 704 if ( ! function_exists( 'is_plugin_active' ) ) {
436 705 require_once ABSPATH . 'wp-admin/includes/plugin.php';
@@ -435,29 +704,53 @@
435 704 if ( ! function_exists( 'is_plugin_active' ) ) {
436 705 require_once ABSPATH . 'wp-admin/includes/plugin.php';
437 706 }
438 707
439 - $known = array(
440 - 'wp-rocket/wp-rocket.php' => 'WP Rocket',
441 - 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
442 - 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
443 - 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
444 - 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
445 - 'cache-enabler/cache-enabler.php' => 'Cache Enabler',
446 - 'comet-cache/comet-cache.php' => 'Comet Cache',
447 - 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
448 - 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
449 - 'breeze/breeze.php' => 'Breeze',
450 - 'autoptimize/autoptimize.php' => 'Autoptimize',
451 - 'flying-press/flying-press.php' => 'FlyingPress',
452 - 'nitropack/main.php' => 'NitroPack',
453 - );
454 -
455 - $active = array();
456 - foreach ( $known as $file => $label ) {
708 + $found = array();
709 + foreach ( Cache_Plugin_Catalog::with_capability( Cache_Plugin_Catalog::CAP_PAGE_CACHE ) as $file => $entry ) {
710 + // xSpeed is in the catalog — it is a page cache, and the detector
711 + // needs to be able to name our own drop-in. It is not a conflict
712 + // with itself, and listing it told every site running us to
713 + // deactivate us to avoid double-caching.
714 + if ( 'xspeed/xspeed.php' === $file ) {
715 + continue;
716 + }
457 717 if ( is_plugin_active( $file ) ) {
458 - $active[] = $label;
718 + $found[] = $entry['label'];
459 719 }
460 720 }
461 - return $active;
721 +
722 + $dropin = self::foreign_dropin_label();
723 + if ( null !== $dropin && ! in_array( $dropin, $found, true ) ) {
724 + $found[] = $dropin;
725 + }
726 +
727 + return array_values( array_unique( $found ) );
728 + }
729 +
730 + /**
731 + * The name of whoever owns advanced-cache.php, when it is not xSpeed.
732 + *
733 + * Null when the file is absent or ours. An owner we cannot identify still
734 + * blocks the enable, so it is reported under a generic name rather than
735 + * being silently dropped — "we could not tell" and "there is nothing
736 + * there" are different answers.
737 + */
738 + private static function foreign_dropin_label(): ?string {
739 + $owner = Cache::dropin_owner();
740 + if ( Cache::DROPIN_XSPEED === $owner || Cache::DROPIN_NONE === $owner ) {
741 + return null;
742 + }
743 + if ( Cache::DROPIN_UNREADABLE === $owner ) {
744 + return __( 'an unreadable advanced-cache.php', 'xspeed' );
745 + }
746 +
747 + $contents = @file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only inspection of a drop-in we may not own; failure is reported as an unidentified owner.
748 + $named = is_string( $contents ) ? Cache_Plugin_Catalog::identify_dropin( $contents ) : null;
749 + if ( null !== $named ) {
750 + $entry = Cache_Plugin_Catalog::get( $named );
751 + return (string) ( $entry['label'] ?? $named );
752 + }
753 +
754 + return __( 'an unidentified advanced-cache.php', 'xspeed' );
462 755 }
463 756 }