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