PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
xspeed / includes / class-server-caches.php

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

544 lines 23.4 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. Two adapters ship: LiteSpeed, and the
9 * nginx FastCGI cache reached through the Nginx Helper plugin.
10 *
11 * Each adapter decides for itself which purges are worth forwarding, from the
12 * `intent` and `scope` on the context. They do not answer alike, and the
13 * reasoning for each lives on the adapter — see `forward_nginx_helper()`,
14 * which stands down on a content purge where `forward_litespeed()` does not.
15 *
16 * This is the counterpart to Render_Caches. That one clears caches of RENDERED
17 * OUTPUT owned by page builders; this one clears caches of whole RESPONSES
18 * owned by the web server. Both are integrations with software we do not ship,
19 * and both hang off a public seam so a site can add its own.
20 *
21 * Nothing here touches another plugin's files or runs a shell command. Each
22 * integration calls the documented public API of the plugin it integrates
23 * with, and detects that plugin by class or constant rather than by path — a
24 * renamed plugin folder must not silently disable the integration.
25 *
26 * Tier: Free. xSpeed's tiering rule (FEATURES.md) is that anything LiteSpeed
27 * Cache ships free, xSpeed ships free — and their purge API is free. Gating
28 * this would mean an unlicensed site keeps serving stale HTML from LSCache,
29 * which is a correctness bug, not a paid feature.
30 *
31 * @package XSpeed
32 */
33
34 declare(strict_types=1);
35
36 namespace XSpeed;
37
38 defined( 'ABSPATH' ) || exit;
39
40 final class Server_Caches {
41
42 /*
43 * Forwarding itself is deliberately not a listener. `Cache` calls
44 * forward() directly, before it fires the public purge actions.
45 *
46 * As a listener this would be one callback among many, and WordPress stops
47 * dispatching an action's remaining callbacks when an earlier one throws —
48 * so an unrelated third-party listener's bug could silently skip our
49 * LiteSpeed forwarding, leaving the server serving stale HTML while xSpeed
50 * reported a successful purge. Shipped behaviour should not be hostage to
51 * that. Third parties still extend through `xspeed_purge_server_caches`
52 * below, which runs after we have done our own work.
53 *
54 * Both built-in adapters are reached only from forward(). Neither
55 * registers a hook of its own, so this is the single place that decides
56 * whether a given purge reaches a server cache.
57 *
58 * boot() below is the one exception, and it registers nothing that
59 * forwards — only the end-of-import purge that forward_nginx_helper()'s
60 * import gate depends on.
61 */
62
63 /**
64 * Register the end-of-import purge.
65 *
66 * `forward_nginx_helper()` stands down for the length of an import: a
67 * WXR run fires hundreds of individually-justified purges, and clearing
68 * the whole nginx zone once per imported post is the waste that gate
69 * exists to stop. That trade is only correct if a single purge follows
70 * the import — otherwise the install finishes with nginx still serving
71 * every pre-import page for the rest of its TTL, which is worse than the
72 * waste. This is that purge, and nothing else issues it.
73 *
74 * `import_end` is WordPress's own signal, fired by the WXR importer and
75 * by every importer that follows its lead. An importer that fires
76 * `import_start` and then dies without `import_end` leaves the zone
77 * stale — the same outcome as not having the gate, so no worse than
78 * before, and not worth a `shutdown` fallback that would fire a full
79 * purge on every request that ever touched an importer.
80 */
81 public static function boot(): void {
82 if ( ! function_exists( 'add_action' ) ) {
83 return;
84 }
85 add_action( 'import_end', array( __CLASS__, 'purge_after_import' ) );
86 }
87
88 /**
89 * Clear everything once, now that the import is done.
90 *
91 * `complete` intent, which is what `Cache::purge_all()` announces by
92 * default — and the one intent the import gate lets through, so this
93 * reaches the server layer even though `did_action( 'import_start' )` is
94 * still true for the rest of the request.
95 */
96 public static function purge_after_import(): void {
97 if ( ! class_exists( __NAMESPACE__ . '\\Cache' ) ) {
98 return;
99 }
100 Cache::purge_all( 'import finished' );
101 }
102
103 /**
104 * Forward one purge to every server cache we recognise.
105 *
106 * The public context carries the action an adapter should take:
107 * `urls` purges only the listed response URLs, `site` purges this site's
108 * response cache, and `network` represents a deliberate whole-tree sweep.
109 * Older callers that omit `scope` retain the original url/null behaviour.
110 *
111 * @param array<string,mixed> $context See `xspeed_after_purge_url`.
112 */
113 public static function forward( $context ): void {
114 if ( ! is_array( $context ) ) {
115 return;
116 }
117
118 // A broken built-in adapter must not suppress the public seam. The local
119 // purge already succeeded, and another adapter may still clear the edge.
120 try {
121 self::forward_litespeed( $context );
122 } catch ( \Throwable $e ) {
123 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
124 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
125 error_log( '[xspeed] LiteSpeed response purge failed: ' . $e->getMessage() );
126 }
127 }
128
129 // Its own try, for the same reason the LiteSpeed one has its own: two
130 // server caches can be in front of one site, and a bad day for one
131 // adapter must not leave the other serving stale HTML.
132 try {
133 self::forward_nginx_helper( $context );
134 } catch ( \Throwable $e ) {
135 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
136 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
137 error_log( '[xspeed] nginx FastCGI response purge failed: ' . $e->getMessage() );
138 }
139 }
140
141 /**
142 * Fires so a site can invalidate a server cache xSpeed does not know.
143 *
144 * Same context as the event that triggered it. Use this rather than
145 * subscribing to `xspeed_after_purge_url` directly when you want to
146 * run only after the built-in integrations have had their turn.
147 *
148 * @since 1.2.3
149 *
150 * @param array $context Bounded purge context.
151 */
152 // Use WordPress' dispatcher so current_action(), did_action(), the `all`
153 // hook and observability tools retain native semantics. Cache wraps each
154 // callback one level down so a throwing adapter cannot cancel the ones
155 // queued behind it.
156 Cache::do_action_isolated( 'xspeed_purge_server_caches', $context );
157 }
158
159 /**
160 * LiteSpeed Cache: URL purges plus its response-cache-only full seam.
161 *
162 * URL purges use LiteSpeed's documented `litespeed_purge_url` action. A
163 * site-wide response invalidation calls the public
164 * `LiteSpeed\Purge::purge_all_lscache()` seam added in 7.7. Older releases
165 * expose only the broad purge-all API, so full forwarding deliberately
166 * stands down there. Do not use `litespeed_purge_all`: in 7.9 that also
167 * deletes LiteSpeed
168 * CSS/JS, local-resource, object and opcode caches and may purge its
169 * Cloudflare integration. xSpeed only owns the response invalidation.
170 *
171 * Detected by constant, not plugin path. `LSCWP_V` is defined by the
172 * plugin bootstrap and survives a renamed folder.
173 *
174 * Forwards on every intent, including `content` — unlike the nginx
175 * adapter, which stands down there. See `forward_nginx_helper()` for why
176 * the two differ.
177 *
178 * @param array<string,mixed> $context Public purge context.
179 */
180 private static function forward_litespeed( array $context ): void {
181 if ( ! defined( 'LSCWP_V' ) ) {
182 return;
183 }
184
185 $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
186 $host = isset( $context['host'] ) && is_string( $context['host'] ) ? $context['host'] : '';
187 $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
188 ? $context['scope']
189 : ( '' !== $url ? 'urls' : 'site' );
190
191 if ( 'none' === $scope ) {
192 return;
193 }
194
195 if ( 'site' === $scope || 'network' === $scope ) {
196 // A full purge scoped to ANOTHER site — Multisite::purge_site()
197 // runs inside switch_to_blog(), so the request's LSCache is not
198 // that site's — must not flush ours. `'*'` is the deliberate
199 // whole-tree sweep and does mean everything. An empty host is the
200 // single-site case, where the purge is ours by definition.
201 if ( '' !== $host && '*' !== $host && ! self::host_is_this_site( $host ) ) {
202 return;
203 }
204 if ( is_callable( array( '\\LiteSpeed\\Purge', 'purge_all_lscache' ) ) ) {
205 // LiteSpeed normally prefixes `*` with the current blog ID. A
206 // network response contract needs the raw `*` tag. This is the same
207 // official switch used by its Empty Entire Cache path and does not
208 // invoke its CSS/JS, object or opcode purgers.
209 if ( 'network' === $scope && ! defined( 'LSWCP_EMPTYCACHE' ) ) {
210 define( 'LSWCP_EMPTYCACHE', true );
211 }
212 \LiteSpeed\Purge::purge_all_lscache( 'xSpeed response invalidation' );
213 }
214 return;
215 }
216
217 $urls = array();
218 if ( isset( $context['urls'] ) && is_array( $context['urls'] ) ) {
219 $urls = $context['urls'];
220 } elseif ( '' !== $url ) {
221 $urls = array( $url );
222 }
223 $targets = array();
224 foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $target_url ) {
225 $targets = array_merge( $targets, self::litespeed_targets_for_url( $target_url ) );
226 }
227 foreach ( array_values( array_unique( $targets ) ) as $target ) {
228 do_action( 'litespeed_purge_url', $target );
229 }
230 }
231
232 /**
233 * nginx FastCGI full-page cache, through the Nginx Helper plugin.
234 *
235 * Forwards on every intent, and on `content` only when Nginx Helper is not
236 * purging for itself or `xspeed_nginx_helper_defer_content_purge` says
237 * to. That asymmetry with `forward_litespeed()`, which forwards on all of
238 * them, is deliberate.
239 *
240 * The two server caches are not alike in what a purge costs. LSCache is
241 * per-site and tag-based: a site purge bumps one tag for one blog. The
242 * nginx FastCGI zone is ONE directory per WordPress install, and clearing
243 * it is a recursive unlink of every cached page — on multisite, of every
244 * site on the network. So the blast radius of forwarding is an order of
245 * magnitude apart for the same event.
246 *
247 * The other half is that we are not the only one purging. Nginx Helper
248 * hooks `transition_post_status`, `before_delete_post` and the comment
249 * hooks itself and purges only the URLs the edit touched (the post, the
250 * homepage, the post's archives), behind its own `enable_purge` option and
251 * an import guard. Its term hooks purge the homepage alone, which is why
252 * a renamed or deleted term is `presentation` and still forwards. On a content
253 * purge it has already done the narrow, correct thing. Forwarding on top
254 * of that replaced targeted purging with a whole-install wipe at the same
255 * frequency: publishing one post cleared every cached page on the site,
256 * and an import cost one full wipe per post. (QA #444.)
257 *
258 * That argument only holds while Nginx Helper's `enable_purge` is on. It
259 * defaults to off, and with it off Nginx Helper purges nothing on a
260 * content edit. Standing down there left the edited post stale at the
261 * server for the whole TTL, where before this adapter existed it was
262 * cleared. So a content purge forwards when Nginx Helper is not purging
263 * for itself. (QA #448)
264 *
265 * The trade that stays: Nginx Helper purges the post, the homepage and
266 * the post's archives. An ordinary page that lists recent posts is none of
267 * those, and keeps its old list until the server TTL expires. The
268 * `xspeed_nginx_helper_defer_content_purge` filter returns to clearing
269 * the whole zone on every content purge outside an import, for a site that
270 * needs those pages current.
271 *
272 * `presentation` and `complete` still forward, because neither of those is
273 * something Nginx Helper covers. It has no hook for `switch_theme`,
274 * `activated_plugin` or `wp_update_nav_menu`, and no notion of a settings
275 * write or a core update — and each of those changes the markup of every
276 * page, not a listed few. An unrecognised intent forwards too: a purge
277 * whose reason we do not know is likelier to need the server layer than
278 * not, and a redundant purge costs a cold cache while a skipped one costs
279 * wrong HTML for the whole TTL.
280 *
281 * Whether LiteSpeed should also stand down on `content` is a fair question
282 * and was deliberately not revisited here — it has no targeted self-purge
283 * to fall back on, so standing it down would leave LSCache stale where
284 * nginx is merely over-cleared.
285 *
286 * @param array<string,mixed> $context Public purge context.
287 */
288 private static function forward_nginx_helper( array $context ): void {
289 // Guarded rather than assumed: Free is upgraded as a unit, but a
290 // half-copied update can leave this file newer than that one.
291 if ( ! class_exists( __NAMESPACE__ . '\\Host_Page_Caches' ) ) {
292 return;
293 }
294
295 $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
296 $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
297 ? $context['scope']
298 : ( '' !== $url ? 'urls' : 'site' );
299
300 // `urls` is a per-URL purge, which this integration does not do yet —
301 // see Host_Page_Caches. Standing down is the honest answer: the
302 // alternative, treating a one-page purge as a reason to clear the
303 // whole install, is the bug this method exists to fix.
304 if ( 'urls' === $scope || 'none' === $scope ) {
305 return;
306 }
307
308 $intent = isset( $context['intent'] ) && is_string( $context['intent'] ) && '' !== $context['intent']
309 ? $context['intent']
310 : 'complete';
311
312 // Nothing to decide on a site with no nginx zone, so the filter below
313 // is only asked when there is one.
314 if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
315 return;
316 }
317
318 if ( 'content' === $intent ) {
319 /**
320 * Whether a content purge (a post saved, a comment approved, a
321 * term added) is left to Nginx Helper instead of clearing the
322 * whole nginx cache.
323 *
324 * Defaults to true when Nginx Helper's automatic purging is on,
325 * since it has already purged the post, the homepage and the
326 * post's archives. Return false to clear the whole zone instead,
327 * for a site whose pages list posts somewhere Nginx Helper does
328 * not purge.
329 *
330 * @param bool $defer Whether to leave it to Nginx Helper.
331 * @param array<string,mixed> $context Public purge context.
332 */
333 $defer = (bool) apply_filters(
334 'xspeed_nginx_helper_defer_content_purge',
335 Host_Page_Caches::nginx_helper_purges_changes(),
336 $context
337 );
338 if ( $defer ) {
339 return;
340 }
341 }
342
343 // An import is a long run of legitimate purges that each individually
344 // justify a forward — new terms, new menu items — and together clear
345 // the install's cache hundreds of times for one operation. Nginx
346 // Helper stands its own purging down for exactly this (its
347 // `is_import_request()`), and a single purge after the import is both
348 // cheaper and more correct. An explicit `complete` still goes through:
349 // an operator who presses Purge All mid-import means it.
350 if ( 'complete' !== $intent && self::is_importing() ) {
351 return;
352 }
353
354 // No host check, deliberately — the mirror of the one in
355 // forward_litespeed(). There, a purge aimed at another blog must not
356 // flush THIS request's LSCache, because LSCache is per-site. nginx
357 // keys one zone per install, so the other blog's cached pages live in
358 // the same directory as ours: skipping on a foreign host would leave
359 // the pages the purge was actually for still being served. Pro's
360 // Multisite::purge_site() runs inside switch_to_blog() and reaches
361 // here with that blog's host.
362 Host_Page_Caches::purge_nginx_helper();
363 }
364
365 /**
366 * Whether WordPress is importing content right now.
367 */
368 private static function is_importing(): bool {
369 if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
370 return true;
371 }
372
373 // The WXR importer defines WP_IMPORTING, but not every importer does;
374 // `import_start` is the signal the others share.
375 return function_exists( 'did_action' ) && did_action( 'import_start' ) > 0;
376 }
377
378 /**
379 * Build LiteSpeed targets for one same-site URL.
380 *
381 * @return string[]
382 */
383 private static function litespeed_targets_for_url( string $url ): array {
384 // Only this site's own URLs. `purge_url()` supports cross-site purges
385 // (multisite, WP-CLI, cron), and LSCache is per-site: reducing another
386 // site's URL to a path would have this site's LiteSpeed purge its OWN
387 // /page/ — the wrong entry gone, the intended one still stale, and a
388 // success reported for both. The other site's server cache is not
389 // addressable from here, so we stand down and leave it to a
390 // network-aware listener on `xspeed_purge_server_caches`. (QA review)
391 if ( ! self::is_this_site( $url ) ) {
392 return array();
393 }
394 // Both trailing-slash forms. Our own sweep purges `/about` and
395 // `/about/` because the cache key preserves whichever the request
396 // used, and LiteSpeed tags them separately for the same reason — so
397 // forwarding only the canonical form can leave the other a HIT. Root
398 // stays a single '/'. (QA review; plausible rather than reproduced —
399 // LSCache dedupes identical tags, so the cost of being wrong is one
400 // redundant purge.)
401 return self::slash_forms( self::site_relative( $url ) );
402 }
403
404 /**
405 * A relative target in both trailing-slash forms, deduplicated.
406 *
407 * @return string[]
408 */
409 private static function slash_forms( string $relative ): array {
410 $query = '';
411 $path = $relative;
412 $split = strpos( $relative, '?' );
413 if ( false !== $split ) {
414 $path = substr( $relative, 0, $split );
415 $query = substr( $relative, $split );
416 }
417 if ( '/' === $path || '' === $path ) {
418 return array( $relative );
419 }
420 $bare = rtrim( $path, '/' );
421 // Keep the exact spelling too. `/path///` can be a distinct server key.
422 return array_values( array_unique( array( $path . $query, $bare . $query, $bare . '/' . $query ) ) );
423 }
424
425 /**
426 * Is this URL served by the site we are running as?
427 *
428 * Host and port, because a site on a non-standard port is a different
429 * origin. Unknown either way means no — a purge sent to the wrong cache is
430 * worse than one not sent at all.
431 */
432 private static function is_this_site( string $url ): bool {
433 if ( ! self::host_is_this_site( self::host_of( $url ) ) ) {
434 return false;
435 }
436
437 // On a subdirectory network, equal hosts do not mean equal blogs.
438 if ( function_exists( 'is_multisite' ) && is_multisite()
439 && function_exists( 'get_blog_details' ) && function_exists( 'get_current_blog_id' )
440 ) {
441 $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.
442 if ( ! is_array( $parts ) ) {
443 return false;
444 }
445 $host = isset( $parts['host'] ) ? (string) $parts['host'] : '';
446 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
447 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
448 for ( $take = min( count( $segments ), 2 ); $take >= 0; --$take ) {
449 $candidate = 0 === $take ? '/' : '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
450 $details = get_blog_details( array( 'domain' => $host, 'path' => $candidate ), false );
451 if ( $details && isset( $details->blog_id ) ) {
452 return (int) $details->blog_id === (int) get_current_blog_id();
453 }
454 }
455 }
456
457 return true;
458 }
459
460 /** Compare a host[:port] against the running site's. */
461 private static function host_is_this_site( string $host ): bool {
462 if ( ! function_exists( 'home_url' ) ) {
463 return false;
464 }
465 $ours = self::host_of( (string) home_url( '/' ) );
466 return '' !== $ours && '' !== $host && $ours === strtolower( $host );
467 }
468
469 /**
470 * host[:port] of a URL, lowercased; '' when it has none.
471 *
472 * A port that is the default for the scheme is dropped, because it is not
473 * part of the origin: `https://site.com:443/p/` and `https://site.com/p/`
474 * are the same page, and RFC 3986 6.2.3 says so. Comparing them as raw
475 * strings made `:443` look like a different site, so the purge stood down
476 * and LiteSpeed was told nothing at all — while the caller was told the
477 * page "was already cold". The page kept serving the old copy until its
478 * TTL ran out.
479 *
480 * Reachable from `wp xspeed cache purge-url`, the MCP `purge_url` tool,
481 * and any plugin passing a canonical URL that spells out the port. The
482 * reverse direction was worse: a site whose own `home_url()` carries
483 * `:443` — normal behind a proxy — matched none of its own URLs, so no
484 * per-page purge ever reached the server cache, silently, site-wide.
485 *
486 * A NON-default port is still kept: `site.com:8443` genuinely is a
487 * different origin from `site.com`, and collapsing those would send one
488 * site's purge to another's cache. (QA #348)
489 */
490 private static function host_of( string $url ): string {
491 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
492 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
493 return '';
494 }
495 $host = strtolower( (string) $parts['host'] );
496 if ( empty( $parts['port'] ) ) {
497 return $host;
498 }
499 $port = (int) $parts['port'];
500 $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
501 if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
502 return $host;
503 }
504 return $host . ':' . $port;
505 }
506
507 /**
508 * Reduce an absolute URL to the site-relative path LiteSpeed keys on.
509 *
510 * LiteSpeed does this itself in `Utility::make_relative()`, by stripping a
511 * `LSCWP_DOMAIN` built with `HTTP_URL_STRIP_ALL` — which strips the PORT.
512 * On a site served from a non-standard port, `http://host:8244/page/` has
513 * `http://host` removed and becomes `:8244/page/`, which is not a valid
514 * URI tag, so the purge silently matches nothing and the server keeps
515 * serving the page. Measured on OpenLiteSpeed 1.8.2 with LiteSpeed Cache
516 * 7.9: an absolute URL left the entry a HIT, the same purge sent as a path
517 * turned it into a MISS.
518 *
519 * Sending the path sidesteps their parsing entirely and is what they
520 * ultimately hash, so it is correct on standard ports too — this is not a
521 * workaround we would want to remove once they fix it.
522 *
523 * Query strings are preserved: LiteSpeed tags them separately, and a purge
524 * for `/shop/` should not silently claim to have cleared `/shop/?page=2`.
525 */
526 private static function site_relative( string $url ): string {
527 $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.
528 if ( ! is_array( $parts ) ) {
529 return $url;
530 }
531 // An absolute origin with no path is the homepage. LiteSpeed expects
532 // '/', never the original absolute URL. Preserve a root query below.
533 $path = isset( $parts['path'] ) && '' !== (string) $parts['path'] ? (string) $parts['path'] : '/';
534 $relative = '/' . ltrim( $path, '/' );
535 // isset(), not empty(): a query of "0" is a real, distinct cache entry
536 // and empty() calls it falsy, so `/shop/?0` would be sent as `/shop/`
537 // and leave the entry the caller named stale.
538 if ( isset( $parts['query'] ) && '' !== (string) $parts['query'] ) {
539 $relative .= '?' . $parts['query'];
540 }
541 return $relative;
542 }
543 }
544