PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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/modules/Cdn/CdnModule.php +310 -15 1.0.31.3.2 View file →
@@ -11,8 +11,10 @@
11 11 declare(strict_types=1);
12 12
13 13 namespace XSpeed\Modules\Cdn;
14 14
15 +defined( 'ABSPATH' ) || exit;
16 +
15 17 use XSpeed\Cdn_Rewriter;
16 18 use XSpeed\Module;
17 19
18 20 final class CdnModule extends Module {
@@ -22,11 +24,11 @@
22 24 public const VERSION = '1.0.0';
23 25
24 26 public function ui_metadata(): array {
25 27 return array(
26 - 'label' => 'CDN',
28 + 'label' => __( 'CDN', 'xspeed' ),
27 29 'icon' => 'Globe',
28 - 'description' => 'Serve static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.',
30 + 'description' => __( 'Serve static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.', 'xspeed' ),
29 31 );
30 32 }
31 33
32 34 public function settings_schema(): array {
@@ -33,30 +35,33 @@
33 35 return array(
34 36 'enabled' => array(
35 37 'type' => 'bool',
36 38 'default' => false,
37 - 'label' => 'Enable CDN',
38 - 'description' => 'Rewrite static asset URLs to the CDN hostname below. Your CDN must be a pull-zone configured to fetch from this site.',
39 + 'label' => __( 'Enable CDN', 'xspeed' ),
40 + 'description' => __( 'Rewrite static asset URLs to the CDN hostname below. Your CDN must be a pull-zone configured to fetch from this site.', 'xspeed' ),
39 41 ),
40 42 'cdn_url' => array(
41 43 'type' => 'string',
42 44 'default' => '',
43 - 'label' => 'CDN URL',
44 - 'description' => 'CDN hostname, e.g. cdn.example.com. https:// and trailing slashes are stripped automatically.',
45 + 'label' => __( 'CDN URL', 'xspeed' ),
46 + 'description' => __( 'CDN hostname, e.g. cdn.example.com. https:// and trailing slashes are stripped automatically.', 'xspeed' ),
47 + 'dependsOn' => array( 'field' => 'enabled' ),
45 48 ),
46 49 'included_extensions' => array(
47 50 'type' => 'list',
48 51 'default' => Cdn_Rewriter::DEFAULT_EXTENSIONS,
49 52 'item_type' => 'string',
50 - 'label' => 'Included File Extensions',
51 - 'description' => 'Only URLs ending in these extensions are rewritten. Defaults cover images, fonts, CSS, JS, and common media.',
53 + 'label' => __( 'Included File Extensions', 'xspeed' ),
54 + 'description' => __( 'Only URLs ending in these extensions are rewritten. Defaults cover images, fonts, CSS, JS, and common media.', 'xspeed' ),
55 + 'dependsOn' => array( 'field' => 'enabled' ),
52 56 ),
53 57 'excluded_patterns' => array(
54 58 'type' => 'list',
55 59 'default' => array(),
56 60 'item_type' => 'string',
57 - 'label' => 'Excluded Patterns',
58 - 'description' => 'Glob patterns matched against the URL path. Matching URLs stay on the origin. Examples: /wp-admin/*, *.pdf, /private/*',
61 + 'label' => __( 'Excluded Patterns', 'xspeed' ),
62 + 'description' => __( 'Glob patterns matched against the URL path. Matching URLs stay on the origin. Examples: /wp-admin/*, *.pdf, /private/*', 'xspeed' ),
63 + 'dependsOn' => array( 'field' => 'enabled' ),
59 64 ),
60 65 );
61 66 }
62 67
@@ -71,27 +76,316 @@
71 76 );
72 77 }
73 78
74 79 public function boot(): void {
80 + /*
81 + * Deferred to `init`. This module reads its own settings to decide
82 + * what to hook, and reading settings builds settings_schema(), whose
83 + * labels are declared through __(). boot() runs on `plugins_loaded`,
84 + * before `after_setup_theme` — the point WordPress 6.7+ treats as the
85 + * earliest safe moment to translate — so doing that here fires
86 + * _load_textdomain_just_in_time on every request AND resolves the
87 + * labels against a domain that is not loaded yet.
88 + *
89 + * Everything below hooks actions that fire after `init`, so running
90 + * one hook later is equivalent.
91 + */
92 + add_action( 'init', array( $this, 'boot_on_init' ) );
93 + }
94 +
95 + /**
96 + * The real boot body — see boot() for why it runs on `init`.
97 + */
98 + public function boot_on_init(): void {
75 99 // Always-on: normalize cdn_url on save (admin context too).
76 100 add_filter( 'pre_update_option_xspeed_module_cdn', array( $this, 'normalize_on_save' ), 10, 1 );
77 101
102 + // CDN URLs are baked into cached HTML, so a settings change that
103 + // isn't followed by a purge is invisible: the user edits the CDN
104 + // host, reloads, sees the old host still served from cache, and
105 + // concludes the feature is broken. Also keeps the font-CORS rules
106 + // in .htaccess in step with the enabled flag.
107 + add_action( 'update_option_xspeed_module_cdn', array( $this, 'on_settings_change' ), 10, 0 );
108 +
78 109 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
79 110 return;
80 111 }
112 +
113 + // Rewriting asset hosts under a builder editor sends the editor's own
114 + // scripts to the CDN, where the copy can be stale or absent. (#281)
115 + if ( \XSpeed\Builder_Editor::is_active() ) {
116 + return;
117 + }
81 118 $opts = $this->get_settings();
82 119 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
83 120 return;
84 121 }
85 122 Cdn_Rewriter::reset_state();
86 - // Late filter — same convention as Lazy_Loader. Runs after
87 - // shortcodes / blocks / embeds finish injecting tags.
88 - add_filter( 'the_content', array( Cdn_Rewriter::class, 'process_html' ), 1000 );
89 - add_filter( 'post_thumbnail_html', array( Cdn_Rewriter::class, 'process_html' ), 1000 );
90 - add_filter( 'widget_text_content', array( Cdn_Rewriter::class, 'process_html' ), 1000 );
123 +
124 + // Attachment URLs still go through their own filter: media-library
125 + // URLs are frequently consumed as PHP strings (feeds, oEmbed, REST
126 + // echoes) rather than emitted into the page HTML we rewrite below.
91 127 add_filter( 'wp_get_attachment_url', array( $this, 'rewrite_attachment_url' ), 1000 );
128 +
129 + // Preconnect to the CDN host. Every asset on the page now resolves
130 + // there, so paying the DNS + TLS handshake once up front rather than
131 + // on first asset request is worth the one tag.
132 + add_filter( 'wp_resource_hints', array( $this, 'add_preconnect' ), 10, 2 );
133 +
134 + // Whole-page pass.
135 + //
136 + // This module used to hook only the_content, post_thumbnail_html and
137 + // widget_text_content — four filters that between them can never
138 + // contain a stylesheet, a script or a font. So `css`, `js` and the
139 + // five font extensions shipped ticked by default and rewrote nothing:
140 + // a user enabled the CDN, saw them enabled, and found zero requests
141 + // in their pull zone.
142 + //
143 + // Enqueued assets can't be reached with those filters at all, and
144 + // hooking style_loader_src/script_loader_src would still miss inline
145 + // url(), hardcoded theme-template images and third-party echo output.
146 + // One pass over the finished page catches every category at once.
147 + //
148 + // It also fixes the srcset split: core builds srcset from
149 + // wp_get_upload_dir() and never calls wp_get_attachment_url(), so a
150 + // theme image previously got a CDN `src` and an origin `srcset` in
151 + // the same tag.
152 + //
153 + // Cost: on the cache-write path this runs once per MISS and the CDN
154 + // URLs bake into the stored HTML, so cache HITs pay nothing. This is
155 + // what Powered Cache, Breeze and SpeedyCache all do. The trade-off is
156 + // that turning the CDN off needs a cache purge — handled by
157 + // purge_on_change() below.
158 + add_filter(
159 + 'xspeed_cache_final_html',
160 + static function ( $html ) {
161 + if ( ! self::should_rewrite_request() ) {
162 + return $html;
163 + }
164 + return Cdn_Rewriter::process_html( (string) $html );
165 + },
166 + // After Resource Hints (10) so any preload/preconnect tag it
167 + // injects gets its URL rewritten too.
168 + 20,
169 + 1
170 + );
171 +
172 + // Cache-off path: the filter above never fires, so buffer the page
173 + // ourselves. Guarded so we never double-buffer when the cache engine
174 + // is running.
175 + if ( ! $this->cache_enabled() ) {
176 + add_action(
177 + 'template_redirect',
178 + static function () {
179 + if ( self::$buffering || ! self::should_rewrite_request() ) {
180 + return;
181 + }
182 + self::$buffering = true;
183 + ob_start(
184 + static function ( $buffer ) {
185 + if ( strlen( (string) $buffer ) < 255 ) {
186 + return $buffer;
187 + }
188 + return Cdn_Rewriter::process_html( (string) $buffer );
189 + }
190 + );
191 + },
192 + 9
193 + );
194 + }
92 195 }
93 196
197 + /**
198 + * Guard against opening our buffer twice on one request.
199 + *
200 + * @var bool
201 + */
202 + private static $buffering = false;
203 +
204 + /**
205 + * Should this request have its asset URLs rewritten at all?
206 + *
207 + * The module's original bail set covered admin / AJAX / cron / REST only.
208 + * These four are the remaining request types where a CDN URL is either
209 + * wrong or actively unhelpful:
210 + *
211 + * - Previews render unsaved content for one logged-in author; pointing
212 + * their assets at a pull zone caches a draft at the edge.
213 + * - robots.txt and trackbacks are not HTML and have no assets.
214 + * - Non-GET requests are form posts and API calls, never a page whose
215 + * asset URLs matter.
216 + */
217 + public static function should_rewrite_request(): bool {
218 + $method = isset( $_SERVER['REQUEST_METHOD'] )
219 + ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
220 + : 'GET';
221 + if ( 'GET' !== $method && 'HEAD' !== $method ) {
222 + return false;
223 + }
224 + if ( function_exists( 'is_preview' ) && is_preview() ) {
225 + return false;
226 + }
227 + if ( function_exists( 'is_robots' ) && is_robots() ) {
228 + return false;
229 + }
230 + if ( function_exists( 'is_trackback' ) && is_trackback() ) {
231 + return false;
232 + }
233 + if ( function_exists( 'is_feed' ) && is_feed() ) {
234 + return false;
235 + }
236 +
237 + /**
238 + * Final say on whether to rewrite asset URLs for this request.
239 + *
240 + * @param bool $should Whether to rewrite.
241 + */
242 + return (bool) apply_filters( 'xspeed_cdn_should_rewrite', true );
243 + }
244 +
245 + /**
246 + * Is the page cache on? When it is, Cache::finalize_buffer() runs and our
247 + * xspeed_cache_final_html filter fires — so we must NOT also ob_start().
248 + */
249 + private function cache_enabled(): bool {
250 + $legacy = \XSpeed\Settings_Manager::get( 'legacy' );
251 + if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
252 + return true;
253 + }
254 + $opts = get_option( 'xspeed_options' );
255 + return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
256 + }
257 +
258 + /**
259 + * Settings changed — purge the page cache and re-sync the font-CORS
260 + * rules in .htaccess.
261 + */
262 + public function on_settings_change(): void {
263 + $this->sync_font_cors();
264 + if ( class_exists( '\\XSpeed\\Cache' ) ) {
265 + \XSpeed\Cache::purge_all( 'cdn settings change' );
266 + // purge_all() only reaches what we wrote. The attachment-URL
267 + // filter below runs DURING render, so a page builder that caches
268 + // rendered output has already stored the old host — Elementor
269 + // keeps it in `_elementor_element_cache` for 24 h and in
270 + // `uploads/elementor/css/post-<id>.css` with no expiry at all.
271 + // Without this, turning the CDN OFF keeps serving the dead host
272 + // (images 404 once the pull zone lapses) and turning it ON leaves
273 + // the LCP hero on the origin — both for a day or more, both after
274 + // a purge the user watched succeed.
275 + \XSpeed\Cache::purge_render_caches( 'cdn settings change' );
276 + }
277 + }
278 +
279 + /**
280 + * Write (or remove) the Apache/LiteSpeed font-CORS block.
281 + *
282 + * nginx hosts get the same directives through nginx_directives() and the
283 + * unified server-block snippet instead — we can't write their config.
284 + */
285 + public function sync_font_cors(): void {
286 + if ( ! class_exists( '\\XSpeed\\Server' ) || ! \XSpeed\Server::supports_htaccess() ) {
287 + return;
288 + }
289 + if ( ! function_exists( 'insert_with_markers' ) ) {
290 + require_once ABSPATH . 'wp-admin/includes/misc.php';
291 + }
292 + if ( ! function_exists( 'insert_with_markers' ) ) {
293 + return;
294 + }
295 +
296 + $opts = $this->get_settings();
297 + $active = ! empty( $opts['enabled'] ) && ! empty( $opts['cdn_url'] );
298 +
299 + $rules = $active
300 + ? array(
301 + '<IfModule mod_headers.c>',
302 + ' # Allow the CDN to pull webfonts cross-origin.',
303 + ' <FilesMatch "\\.(woff2?|ttf|otf|eot)$">',
304 + ' Header always set Access-Control-Allow-Origin "*"',
305 + ' </FilesMatch>',
306 + '</IfModule>',
307 + )
308 + : array();
309 +
310 + // ABSPATH rather than get_home_path(): that function lives in
311 + // wp-admin/includes/file.php, which is not loaded on a REST, CLI or
312 + // cron request — and because this class is namespaced, the
313 + // unqualified call resolved to XSpeed\Modules\Cdn\get_home_path()
314 + // and fatalled on every real save, including disabling the module.
315 + // This mirrors class-gzip.php, and the file_exists() guard it brings
316 + // also stops insert_with_markers() creating a stray .htaccess at the
317 + // WP root on a subdirectory install.
318 + $htaccess = ABSPATH . '.htaccess';
319 + if ( ! file_exists( $htaccess ) ) {
320 + // Nothing to amend, and nothing to clean up.
321 + if ( empty( $rules ) ) {
322 + return;
323 + }
324 + if ( ! is_writable( ABSPATH ) ) {
325 + return;
326 + }
327 + }
328 +
329 + insert_with_markers( $htaccess, 'xSpeed CDN', $rules );
330 + }
331 +
332 + /**
333 + * Font CORS for the origin.
334 + *
335 + * We ship the five font extensions enabled by default, and now that CSS
336 + * actually reaches the CDN, `@font-face` inside those stylesheets
337 + * resolves against the CDN host too. A font fetched cross-origin is a
338 + * CORS request: without `Access-Control-Allow-Origin` on the ORIGIN
339 + * response, the CDN caches a response the browser then refuses, and every
340 + * webfont silently falls back to a system face.
341 + *
342 + * This was latent before — nothing reached the CDN, so nothing broke.
343 + * Fixing the rewrite without this would turn a dead setting into a live
344 + * regression, which is why it ships in the same change.
345 + *
346 + * @return string|null nginx directives, or null when the CDN is off.
347 + */
348 + public function nginx_directives(): ?string {
349 + $opts = $this->get_settings();
350 + if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
351 + return null;
352 + }
353 + return "# Allow the CDN to pull webfonts cross-origin.\n"
354 + . "location ~* \\.(woff2?|ttf|otf|eot)$ {\n"
355 + . " add_header Access-Control-Allow-Origin \"*\" always;\n"
356 + . "}";
357 + }
358 +
359 + /**
360 + * Emit a preconnect hint for the CDN host.
361 + *
362 + * @param array $hints URLs for this relation type.
363 + * @param string $relation_type One of dns-prefetch / preconnect / …
364 + * @return array
365 + */
366 + public function add_preconnect( $hints, $relation_type ) {
367 + if ( 'preconnect' !== $relation_type || ! is_array( $hints ) ) {
368 + return $hints;
369 + }
370 + if ( Cdn_Rewriter::is_dev_host() ) {
371 + return $hints;
372 + }
373 + $opts = $this->get_settings();
374 + $host = Cdn_Rewriter::normalize_host( (string) ( $opts['cdn_url'] ?? '' ) );
375 + if ( '' === $host ) {
376 + return $hints;
377 + }
378 + // crossorigin so the hint also warms the connection fonts will use —
379 + // font requests are CORS requests and would otherwise open a second
380 + // connection.
381 + $hints[] = array(
382 + 'href' => '//' . $host,
383 + 'crossorigin' => 'anonymous',
384 + );
385 + return $hints;
386 + }
387 +
94 388 public function rewrite_attachment_url( $url ) {
95 389 if ( ! is_string( $url ) || '' === $url ) {
96 390 return $url;
97 391 }
@@ -120,8 +414,9 @@
120 414 array(
121 415 'name' => 'xspeed cdn',
122 416 'callback' => array( $this, 'cli_handler' ),
123 417 'shortdesc' => 'Show CDN settings + test rewriting a URL.',
418 + 'ai_hint' => 'Is a CDN configured, and does URL rewriting work? Use to check whether assets are served from the CDN, or to test what a given URL rewrites to before trusting the setting.',
124 419 'synopsis' => array(
125 420 array(
126 421 'type' => 'positional',
127 422 'name' => 'action',