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-browser-cache.php +120 -22 1.1.71.3.3 View file →
@@ -1,9 +1,10 @@
1 1 <?php
2 2 /**
3 3 * Browser_Cache — writes/removes browser-cache directives in the site
4 4 * root .htaccess so static assets get long Cache-Control + Expires
5 - * headers, and serves an nginx snippet for non-Apache hosts.
5 + * headers, and serves an nginx snippet for non-Apache hosts (that one
6 + * sends Cache-Control only — see nginx_snippet()).
6 7 *
7 8 * Same shape as Gzip: marker block, insert_with_markers, snippet
8 9 * fallback. Independent toggle so users can enable browser caching
9 10 * without compression and vice versa.
@@ -93,27 +94,43 @@
93 94 );
94 95 }
95 96
96 97 /**
97 - * nginx snippet — uses `expires` directive (the canonical nginx way)
98 - * plus an `add_header` line for the immutable flag.
99 - */
100 - /**
101 - * Probe whether the server is actually emitting the Cache-Control
102 - * headers our nginx snippet is supposed to add. Picks a recognisable
103 - * static asset (anything in `wp-includes/css/` is always served on
104 - * a WP install) and HEADs it, looking for `immutable` in the response
105 - * — the unique fingerprint our snippet adds that WP core's defaults
106 - * never set. Cached in a 5-minute transient so this never adds
107 - * latency to the dashboard.
98 + * Probe whether long-lived caching headers are actually reaching the
99 + * browser. Picks a recognisable static asset (anything in
100 + * `wp-includes/css/` is always served on a WP install) and HEADs it.
101 + * Cached in a transient so this never adds latency to the dashboard.
108 102 *
103 + * We test for the EFFECT, not for our own configuration (issue #329).
104 + * The old check looked for `immutable` — the fingerprint *our* snippet
105 + * writes — so any site whose headers come from another layer (an
106 + * xCloud-generated vhost, a container nginx, a reverse proxy) was told
107 + * "enabled but not active on the server" while demonstrably serving
108 + * `Cache-Control: public, max-age=315360000` on every asset. That is a
109 + * false alarm the operator cannot dismiss, and it pushed them toward
110 + * pasting a snippet that would add a second, conflicting `location`
111 + * block. A long `max-age`, an `Expires` date in the future, or
112 + * `immutable` all mean the same thing to a browser, so all three count.
113 + *
109 114 * Returns:
110 - * true → headers present, no notice needed
111 - * false → headers absent, snippet hasn't been pasted yet (or hasn't
112 - * been reloaded into nginx), keep the notice up
115 + * true → caching headers present (ours or another layer's), no notice
116 + * false → proven absent: nothing is being sent, keep the notice up
117 + * null → no verdict; the loopback never completed (firewalled, TLS
118 + * failure, timeout, WAF answering instead of the origin)
119 + *
120 + * The third state matters for the same reason it does on the GZIP probe
121 + * (issue #18): folding "couldn't ask" into "the answer is no" pins a
122 + * permanent warning onto sites where only the loopback is broken. Only a
123 + * *proven* false may nag the user. A non-verdict is cached for a minute
124 + * only, so a transient blip resolves itself on the next page load.
125 + *
126 + * @return bool|null
113 127 */
114 - public static function probe_headers_present(): bool {
128 + public static function probe_headers_present() {
115 129 $cached = get_transient( 'xspeed_browser_cache_probe' );
130 + if ( '?' === $cached ) {
131 + return null;
132 + }
116 133 if ( null !== $cached && false !== $cached ) {
117 134 return (bool) $cached;
118 135 }
119 136
@@ -126,11 +143,11 @@
126 143 'redirection' => 0,
127 144 'headers' => array( 'Cache-Control' => 'no-cache' ),
128 145 )
129 146 );
130 - if ( is_wp_error( $resp ) ) {
131 - set_transient( 'xspeed_browser_cache_probe', 0, MINUTE_IN_SECONDS );
132 - return false;
147 + if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
148 + set_transient( 'xspeed_browser_cache_probe', '?', MINUTE_IN_SECONDS );
149 + return null;
133 150 }
134 151 // wp_remote_retrieve_header() returns a STRING for a single header
135 152 // but an ARRAY when the header appears more than once (common behind
136 153 // CDNs / proxies, or nginx with multiple add_header lines). Casting
@@ -139,25 +156,106 @@
139 156 // check below silently false-negatives. Normalize array → string
140 157 // first. (FBS-82141)
141 158 $raw = wp_remote_retrieve_header( $resp, 'cache-control' );
142 159 $cache_control = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw;
143 - $active = false !== stripos( $cache_control, 'immutable' );
160 + $raw_expires = wp_remote_retrieve_header( $resp, 'expires' );
161 + $expires = is_array( $raw_expires ) ? implode( ', ', $raw_expires ) : (string) $raw_expires;
162 +
163 + $active = self::headers_indicate_caching( $cache_control, $expires );
144 164 set_transient( 'xspeed_browser_cache_probe', $active ? 1 : 0, 5 * MINUTE_IN_SECONDS );
145 165 return $active;
146 166 }
147 167
168 + /**
169 + * Do these response headers tell a browser to cache the asset for a
170 + * meaningful length of time?
171 + *
172 + * Any of three signals counts, because a browser honours all three
173 + * equally and we must not privilege the one our own snippet happens to
174 + * write (issue #329):
175 + *
176 + * - `immutable` — what our snippet adds
177 + * - a long `max-age` — what most host templates emit
178 + * - a future `Expires` — the older directive, still what nginx's
179 + * `expires` emits alongside `Cache-Control`
180 + *
181 + * An explicit no-store / no-cache / max-age=0 is a proven negative and
182 + * wins over everything else: that is a server actively refusing to let
183 + * the asset be cached, which is exactly the state worth warning about.
184 + *
185 + * The one-hour floor keeps WP core's own short defaults from reading as
186 + * "browser caching is configured".
187 + *
188 + * Pure — unit-tested.
189 + */
190 + public static function headers_indicate_caching( string $cache_control, string $expires = '' ): bool {
191 + if ( preg_match( '#\b(?:no-store|no-cache)\b#i', $cache_control ) ) {
192 + return false;
193 + }
194 + if ( preg_match( '#\bmax-age\s*=\s*(\d+)#i', $cache_control, $m ) ) {
195 + return (int) $m[1] >= HOUR_IN_SECONDS;
196 + }
197 + if ( false !== stripos( $cache_control, 'immutable' ) ) {
198 + return true;
199 + }
200 + if ( '' !== $expires ) {
201 + $ts = strtotime( $expires );
202 + // A past date (or the literal "0" some servers send) means
203 + // "already stale", not "cached".
204 + return false !== $ts && $ts > time() + HOUR_IN_SECONDS;
205 + }
206 + return false;
207 + }
208 +
209 + /**
210 + * nginx snippet — one `add_header Cache-Control` per location, with
211 + * `expires off;` pinned in front of it.
212 + *
213 + * nginx's `expires Ns;` emits its own `Cache-Control: max-age=N` (plus
214 + * `Expires`), and `add_header` appends rather than replaces, so pairing
215 + * the two — as this block used to — put two Cache-Control fields on
216 + * every static asset it matched, WP core's own included (issue #259).
217 + * `expires` can't say `immutable` and `immutable` is the point, so
218 + * `add_header` is the one that stays.
219 + *
220 + * `off` rather than simply dropping the directive: `expires` is
221 + * inherited from `server {}`, so a host template that sets one there
222 + * would recreate the duplicate inside this location.
223 + *
224 + * Losing `Expires:` on nginx costs nothing — `max-age` outranks it in
225 + * any HTTP/1.1 cache, and Apache still emits both via mod_expires.
226 + *
227 + * Deliberately no `always`. Without it nginx applies `add_header` only
228 + * to the statuses its header filter treats as safe — 200, 201, 204,
229 + * 206, 301, 302, 303, 304, 307, 308 — which is the coverage we want,
230 + * and the same coverage Apache gives: `Header set` in apache_rules()
231 + * runs under the default `onsuccess` condition, so it is off on error
232 + * responses too. `always` would put `max-age=31536000, immutable` on a
233 + * 404, and minified asset URLs are deterministic (Minifier keys them on
234 + * path + mtime), so a file that 404s in the window after a purge comes
235 + * back at the URL a client has already cached the 404 for — for a year,
236 + * with no revalidation.
237 + */
148 238 public static function nginx_snippet( array $opts = array() ): string {
149 239 $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL );
150 240 $html = (int) ( $opts['html_ttl'] ?? self::DEFAULT_HTML_TTL );
241 + // Same guard as apache_rules(): a negative TTL would otherwise
242 + // reach the vhost as `max-age=-1`.
243 + if ( $asset < 0 ) {
244 + $asset = self::DEFAULT_ASSET_TTL;
245 + }
246 + if ( $html < 0 ) {
247 + $html = self::DEFAULT_HTML_TTL;
248 + }
151 249 return implode(
152 250 "\n",
153 251 array(
154 252 'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {',
155 - ' expires ' . $asset . 's;',
253 + ' expires off;',
156 254 ' add_header Cache-Control "public, max-age=' . $asset . ', immutable";',
157 255 '}',
158 256 'location ~* \.html$ {',
159 - ' expires ' . $html . 's;',
257 + ' expires off;',
160 258 ' add_header Cache-Control "public, max-age=' . $html . '";',
161 259 '}',
162 260 )
163 261 );