PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 / class-server-caches.php

class-server-caches.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, at includes/class-server-caches.php

328 lines 13.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server_Caches — forward xSpeed's purges to a cache in front of PHP.
4 *
5 * xSpeed owns one cache. A LiteSpeed stack has two: ours, and LSCache holding
6 * its own copy of the same URL at the server. Purging ours and stopping there
7 * left the server still serving the page we had just invalidated — measured on
8 * OpenLiteSpeed before this existed.
9 *
10 * This is the counterpart to Render_Caches. That one clears caches of RENDERED
11 * OUTPUT owned by page builders; this one clears caches of whole RESPONSES
12 * owned by the web server. Both are integrations with software we do not ship,
13 * and both hang off a public seam so a site can add its own.
14 *
15 * Nothing here touches another plugin's files or runs a shell command. Each
16 * integration calls the documented public API of the plugin it integrates
17 * with, and detects that plugin by class or constant rather than by path — a
18 * renamed plugin folder must not silently disable the integration.
19 *
20 * Tier: Free. xSpeed's tiering rule (FEATURES.md) is that anything LiteSpeed
21 * Cache ships free, xSpeed ships free — and their purge API is free. Gating
22 * this would mean an unlicensed site keeps serving stale HTML from LSCache,
23 * which is a correctness bug, not a paid feature.
24 *
25 * @package XSpeed
26 */
27
28 declare(strict_types=1);
29
30 namespace XSpeed;
31
32 defined( 'ABSPATH' ) || exit;
33
34 final class Server_Caches {
35
36 /*
37 * There is deliberately no boot()/add_action here. `Cache` calls forward()
38 * directly, before it fires the public purge actions.
39 *
40 * As a listener this would be one callback among many, and WordPress stops
41 * dispatching an action's remaining callbacks when an earlier one throws —
42 * so an unrelated third-party listener's bug could silently skip our
43 * LiteSpeed forwarding, leaving the server serving stale HTML while xSpeed
44 * reported a successful purge. Shipped behaviour should not be hostage to
45 * that. Third parties still extend through `xspeed_purge_server_caches`
46 * below, which runs after we have done our own work.
47 */
48
49 /**
50 * Forward one purge to every server cache we recognise.
51 *
52 * The public context carries the action an adapter should take:
53 * `urls` purges only the listed response URLs, `site` purges this site's
54 * response cache, and `network` represents a deliberate whole-tree sweep.
55 * Older callers that omit `scope` retain the original url/null behaviour.
56 *
57 * @param array<string,mixed> $context See `xspeed_after_purge_url`.
58 */
59 public static function forward( $context ): void {
60 if ( ! is_array( $context ) ) {
61 return;
62 }
63
64 // A broken built-in adapter must not suppress the public seam. The local
65 // purge already succeeded, and another adapter may still clear the edge.
66 try {
67 self::forward_litespeed( $context );
68 } catch ( \Throwable $e ) {
69 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
70 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
71 error_log( '[xspeed] LiteSpeed response purge failed: ' . $e->getMessage() );
72 }
73 }
74
75 /**
76 * Fires so a site can invalidate a server cache xSpeed does not know.
77 *
78 * Same context as the event that triggered it. Use this rather than
79 * subscribing to `xspeed_after_purge_url` directly when you want to
80 * run only after the built-in integrations have had their turn.
81 *
82 * @since 1.2.3
83 *
84 * @param array $context Bounded purge context.
85 */
86 // Use WordPress' dispatcher so current_action(), did_action(), the `all`
87 // hook and observability tools retain native semantics. Cache wraps each
88 // callback one level down so a throwing adapter cannot cancel the ones
89 // queued behind it.
90 Cache::do_action_isolated( 'xspeed_purge_server_caches', $context );
91 }
92
93 /**
94 * LiteSpeed Cache: URL purges plus its response-cache-only full seam.
95 *
96 * URL purges use LiteSpeed's documented `litespeed_purge_url` action. A
97 * site-wide response invalidation calls the public
98 * `LiteSpeed\Purge::purge_all_lscache()` seam added in 7.7. Older releases
99 * expose only the broad purge-all API, so full forwarding deliberately
100 * stands down there. Do not use `litespeed_purge_all`: in 7.9 that also
101 * deletes LiteSpeed
102 * CSS/JS, local-resource, object and opcode caches and may purge its
103 * Cloudflare integration. xSpeed only owns the response invalidation.
104 *
105 * Detected by constant, not plugin path. `LSCWP_V` is defined by the
106 * plugin bootstrap and survives a renamed folder.
107 *
108 * @param array<string,mixed> $context Public purge context.
109 */
110 private static function forward_litespeed( array $context ): void {
111 if ( ! defined( 'LSCWP_V' ) ) {
112 return;
113 }
114
115 $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
116 $host = isset( $context['host'] ) && is_string( $context['host'] ) ? $context['host'] : '';
117 $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
118 ? $context['scope']
119 : ( '' !== $url ? 'urls' : 'site' );
120
121 if ( 'none' === $scope ) {
122 return;
123 }
124
125 if ( 'site' === $scope || 'network' === $scope ) {
126 // A full purge scoped to ANOTHER site — Multisite::purge_site()
127 // runs inside switch_to_blog(), so the request's LSCache is not
128 // that site's — must not flush ours. `'*'` is the deliberate
129 // whole-tree sweep and does mean everything. An empty host is the
130 // single-site case, where the purge is ours by definition.
131 if ( '' !== $host && '*' !== $host && ! self::host_is_this_site( $host ) ) {
132 return;
133 }
134 if ( is_callable( array( '\\LiteSpeed\\Purge', 'purge_all_lscache' ) ) ) {
135 // LiteSpeed normally prefixes `*` with the current blog ID. A
136 // network response contract needs the raw `*` tag. This is the same
137 // official switch used by its Empty Entire Cache path and does not
138 // invoke its CSS/JS, object or opcode purgers.
139 if ( 'network' === $scope && ! defined( 'LSWCP_EMPTYCACHE' ) ) {
140 define( 'LSWCP_EMPTYCACHE', true );
141 }
142 \LiteSpeed\Purge::purge_all_lscache( 'xSpeed response invalidation' );
143 }
144 return;
145 }
146
147 $urls = array();
148 if ( isset( $context['urls'] ) && is_array( $context['urls'] ) ) {
149 $urls = $context['urls'];
150 } elseif ( '' !== $url ) {
151 $urls = array( $url );
152 }
153 $targets = array();
154 foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $target_url ) {
155 $targets = array_merge( $targets, self::litespeed_targets_for_url( $target_url ) );
156 }
157 foreach ( array_values( array_unique( $targets ) ) as $target ) {
158 do_action( 'litespeed_purge_url', $target );
159 }
160 }
161
162 /**
163 * Build LiteSpeed targets for one same-site URL.
164 *
165 * @return string[]
166 */
167 private static function litespeed_targets_for_url( string $url ): array {
168 // Only this site's own URLs. `purge_url()` supports cross-site purges
169 // (multisite, WP-CLI, cron), and LSCache is per-site: reducing another
170 // site's URL to a path would have this site's LiteSpeed purge its OWN
171 // /page/ — the wrong entry gone, the intended one still stale, and a
172 // success reported for both. The other site's server cache is not
173 // addressable from here, so we stand down and leave it to a
174 // network-aware listener on `xspeed_purge_server_caches`. (QA review)
175 if ( ! self::is_this_site( $url ) ) {
176 return array();
177 }
178 // Both trailing-slash forms. Our own sweep purges `/about` and
179 // `/about/` because the cache key preserves whichever the request
180 // used, and LiteSpeed tags them separately for the same reason — so
181 // forwarding only the canonical form can leave the other a HIT. Root
182 // stays a single '/'. (QA review; plausible rather than reproduced —
183 // LSCache dedupes identical tags, so the cost of being wrong is one
184 // redundant purge.)
185 return self::slash_forms( self::site_relative( $url ) );
186 }
187
188 /**
189 * A relative target in both trailing-slash forms, deduplicated.
190 *
191 * @return string[]
192 */
193 private static function slash_forms( string $relative ): array {
194 $query = '';
195 $path = $relative;
196 $split = strpos( $relative, '?' );
197 if ( false !== $split ) {
198 $path = substr( $relative, 0, $split );
199 $query = substr( $relative, $split );
200 }
201 if ( '/' === $path || '' === $path ) {
202 return array( $relative );
203 }
204 $bare = rtrim( $path, '/' );
205 // Keep the exact spelling too. `/path///` can be a distinct server key.
206 return array_values( array_unique( array( $path . $query, $bare . $query, $bare . '/' . $query ) ) );
207 }
208
209 /**
210 * Is this URL served by the site we are running as?
211 *
212 * Host and port, because a site on a non-standard port is a different
213 * origin. Unknown either way means no — a purge sent to the wrong cache is
214 * worse than one not sent at all.
215 */
216 private static function is_this_site( string $url ): bool {
217 if ( ! self::host_is_this_site( self::host_of( $url ) ) ) {
218 return false;
219 }
220
221 // On a subdirectory network, equal hosts do not mean equal blogs.
222 if ( function_exists( 'is_multisite' ) && is_multisite()
223 && function_exists( 'get_blog_details' ) && function_exists( 'get_current_blog_id' )
224 ) {
225 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- bounded URL ownership lookup.
226 if ( ! is_array( $parts ) ) {
227 return false;
228 }
229 $host = isset( $parts['host'] ) ? (string) $parts['host'] : '';
230 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
231 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
232 for ( $take = min( count( $segments ), 2 ); $take >= 0; --$take ) {
233 $candidate = 0 === $take ? '/' : '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
234 $details = get_blog_details( array( 'domain' => $host, 'path' => $candidate ), false );
235 if ( $details && isset( $details->blog_id ) ) {
236 return (int) $details->blog_id === (int) get_current_blog_id();
237 }
238 }
239 }
240
241 return true;
242 }
243
244 /** Compare a host[:port] against the running site's. */
245 private static function host_is_this_site( string $host ): bool {
246 if ( ! function_exists( 'home_url' ) ) {
247 return false;
248 }
249 $ours = self::host_of( (string) home_url( '/' ) );
250 return '' !== $ours && '' !== $host && $ours === strtolower( $host );
251 }
252
253 /**
254 * host[:port] of a URL, lowercased; '' when it has none.
255 *
256 * A port that is the default for the scheme is dropped, because it is not
257 * part of the origin: `https://site.com:443/p/` and `https://site.com/p/`
258 * are the same page, and RFC 3986 6.2.3 says so. Comparing them as raw
259 * strings made `:443` look like a different site, so the purge stood down
260 * and LiteSpeed was told nothing at all — while the caller was told the
261 * page "was already cold". The page kept serving the old copy until its
262 * TTL ran out.
263 *
264 * Reachable from `wp xspeed cache purge-url`, the MCP `purge_url` tool,
265 * and any plugin passing a canonical URL that spells out the port. The
266 * reverse direction was worse: a site whose own `home_url()` carries
267 * `:443` — normal behind a proxy — matched none of its own URLs, so no
268 * per-page purge ever reached the server cache, silently, site-wide.
269 *
270 * A NON-default port is still kept: `site.com:8443` genuinely is a
271 * different origin from `site.com`, and collapsing those would send one
272 * site's purge to another's cache. (QA #348)
273 */
274 private static function host_of( string $url ): string {
275 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
276 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
277 return '';
278 }
279 $host = strtolower( (string) $parts['host'] );
280 if ( empty( $parts['port'] ) ) {
281 return $host;
282 }
283 $port = (int) $parts['port'];
284 $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
285 if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
286 return $host;
287 }
288 return $host . ':' . $port;
289 }
290
291 /**
292 * Reduce an absolute URL to the site-relative path LiteSpeed keys on.
293 *
294 * LiteSpeed does this itself in `Utility::make_relative()`, by stripping a
295 * `LSCWP_DOMAIN` built with `HTTP_URL_STRIP_ALL` — which strips the PORT.
296 * On a site served from a non-standard port, `http://host:8244/page/` has
297 * `http://host` removed and becomes `:8244/page/`, which is not a valid
298 * URI tag, so the purge silently matches nothing and the server keeps
299 * serving the page. Measured on OpenLiteSpeed 1.8.2 with LiteSpeed Cache
300 * 7.9: an absolute URL left the entry a HIT, the same purge sent as a path
301 * turned it into a MISS.
302 *
303 * Sending the path sidesteps their parsing entirely and is what they
304 * ultimately hash, so it is correct on standard ports too — this is not a
305 * workaround we would want to remove once they fix it.
306 *
307 * Query strings are preserved: LiteSpeed tags them separately, and a purge
308 * for `/shop/` should not silently claim to have cleared `/shop/?page=2`.
309 */
310 private static function site_relative( string $url ): string {
311 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- path extraction only.
312 if ( ! is_array( $parts ) ) {
313 return $url;
314 }
315 // An absolute origin with no path is the homepage. LiteSpeed expects
316 // '/', never the original absolute URL. Preserve a root query below.
317 $path = isset( $parts['path'] ) && '' !== (string) $parts['path'] ? (string) $parts['path'] : '/';
318 $relative = '/' . ltrim( $path, '/' );
319 // isset(), not empty(): a query of "0" is a real, distinct cache entry
320 // and empty() calls it falsy, so `/shop/?0` would be sent as `/shop/`
321 // and leave the entry the caller named stale.
322 if ( isset( $parts['query'] ) && '' !== (string) $parts['query'] ) {
323 $relative .= '?' . $parts['query'];
324 }
325 return $relative;
326 }
327 }
328