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