'CDN',
'icon' => 'Globe',
'description' => 'Serve static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.',
);
}
public function settings_schema(): array {
return array(
'enabled' => array(
'type' => 'bool',
'default' => false,
'label' => 'Enable CDN',
'description' => 'Rewrite static asset URLs to the CDN hostname below. Your CDN must be a pull-zone configured to fetch from this site.',
),
'cdn_url' => array(
'type' => 'string',
'default' => '',
'label' => 'CDN URL',
'description' => 'CDN hostname, e.g. cdn.example.com. https:// and trailing slashes are stripped automatically.',
'dependsOn' => array( 'field' => 'enabled' ),
),
'included_extensions' => array(
'type' => 'list',
'default' => Cdn_Rewriter::DEFAULT_EXTENSIONS,
'item_type' => 'string',
'label' => 'Included File Extensions',
'description' => 'Only URLs ending in these extensions are rewritten. Defaults cover images, fonts, CSS, JS, and common media.',
'dependsOn' => array( 'field' => 'enabled' ),
),
'excluded_patterns' => array(
'type' => 'list',
'default' => array(),
'item_type' => 'string',
'label' => 'Excluded Patterns',
'description' => 'Glob patterns matched against the URL path. Matching URLs stay on the origin. Examples: /wp-admin/*, *.pdf, /private/*',
'dependsOn' => array( 'field' => 'enabled' ),
),
);
}
public function conflicts(): array {
return array(
array(
'plugin' => 'cdn-enabler/cdn-enabler.php',
'feature' => 'cdn.rewrite',
'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
'reason' => 'CDN Enabler rewrites the same URLs; running both will double-rewrite or produce broken hosts.',
),
);
}
public function boot(): void {
// Always-on: normalize cdn_url on save (admin context too).
add_filter( 'pre_update_option_xspeed_module_cdn', array( $this, 'normalize_on_save' ), 10, 1 );
// CDN URLs are baked into cached HTML, so a settings change that
// isn't followed by a purge is invisible: the user edits the CDN
// host, reloads, sees the old host still served from cache, and
// concludes the feature is broken. Also keeps the font-CORS rules
// in .htaccess in step with the enabled flag.
add_action( 'update_option_xspeed_module_cdn', array( $this, 'on_settings_change' ), 10, 0 );
if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return;
}
$opts = $this->get_settings();
if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
return;
}
Cdn_Rewriter::reset_state();
// Attachment URLs still go through their own filter: media-library
// URLs are frequently consumed as PHP strings (feeds, oEmbed, REST
// echoes) rather than emitted into the page HTML we rewrite below.
add_filter( 'wp_get_attachment_url', array( $this, 'rewrite_attachment_url' ), 1000 );
// Preconnect to the CDN host. Every asset on the page now resolves
// there, so paying the DNS + TLS handshake once up front rather than
// on first asset request is worth the one tag.
add_filter( 'wp_resource_hints', array( $this, 'add_preconnect' ), 10, 2 );
// Whole-page pass.
//
// This module used to hook only the_content, post_thumbnail_html and
// widget_text_content — four filters that between them can never
// contain a stylesheet, a script or a font. So `css`, `js` and the
// five font extensions shipped ticked by default and rewrote nothing:
// a user enabled the CDN, saw them enabled, and found zero requests
// in their pull zone.
//
// Enqueued assets can't be reached with those filters at all, and
// hooking style_loader_src/script_loader_src would still miss inline
// url(), hardcoded theme-template images and third-party echo output.
// One pass over the finished page catches every category at once.
//
// It also fixes the srcset split: core builds srcset from
// wp_get_upload_dir() and never calls wp_get_attachment_url(), so a
// theme image previously got a CDN `src` and an origin `srcset` in
// the same tag.
//
// Cost: on the cache-write path this runs once per MISS and the CDN
// URLs bake into the stored HTML, so cache HITs pay nothing. This is
// what Powered Cache, Breeze and SpeedyCache all do. The trade-off is
// that turning the CDN off needs a cache purge — handled by
// purge_on_change() below.
add_filter(
'xspeed_cache_final_html',
static function ( $html ) {
if ( ! self::should_rewrite_request() ) {
return $html;
}
return Cdn_Rewriter::process_html( (string) $html );
},
// After Resource Hints (10) so any preload/preconnect tag it
// injects gets its URL rewritten too.
20,
1
);
// Cache-off path: the filter above never fires, so buffer the page
// ourselves. Guarded so we never double-buffer when the cache engine
// is running.
if ( ! $this->cache_enabled() ) {
add_action(
'template_redirect',
static function () {
if ( self::$buffering || ! self::should_rewrite_request() ) {
return;
}
self::$buffering = true;
ob_start(
static function ( $buffer ) {
if ( strlen( (string) $buffer ) < 255 ) {
return $buffer;
}
return Cdn_Rewriter::process_html( (string) $buffer );
}
);
},
9
);
}
}
/**
* Guard against opening our buffer twice on one request.
*
* @var bool
*/
private static $buffering = false;
/**
* Should this request have its asset URLs rewritten at all?
*
* The module's original bail set covered admin / AJAX / cron / REST only.
* These four are the remaining request types where a CDN URL is either
* wrong or actively unhelpful:
*
* - Previews render unsaved content for one logged-in author; pointing
* their assets at a pull zone caches a draft at the edge.
* - robots.txt and trackbacks are not HTML and have no assets.
* - Non-GET requests are form posts and API calls, never a page whose
* asset URLs matter.
*/
public static function should_rewrite_request(): bool {
$method = isset( $_SERVER['REQUEST_METHOD'] )
? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
: 'GET';
if ( 'GET' !== $method && 'HEAD' !== $method ) {
return false;
}
if ( function_exists( 'is_preview' ) && is_preview() ) {
return false;
}
if ( function_exists( 'is_robots' ) && is_robots() ) {
return false;
}
if ( function_exists( 'is_trackback' ) && is_trackback() ) {
return false;
}
if ( function_exists( 'is_feed' ) && is_feed() ) {
return false;
}
/**
* Final say on whether to rewrite asset URLs for this request.
*
* @param bool $should Whether to rewrite.
*/
return (bool) apply_filters( 'xspeed_cdn_should_rewrite', true );
}
/**
* Is the page cache on? When it is, Cache::finalize_buffer() runs and our
* xspeed_cache_final_html filter fires — so we must NOT also ob_start().
*/
private function cache_enabled(): bool {
$legacy = \XSpeed\Settings_Manager::get( 'legacy' );
if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
return true;
}
$opts = get_option( 'xspeed_options' );
return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
}
/**
* Settings changed — purge the page cache and re-sync the font-CORS
* rules in .htaccess.
*/
public function on_settings_change(): void {
$this->sync_font_cors();
if ( class_exists( '\\XSpeed\\Cache' ) ) {
\XSpeed\Cache::purge_all( 'cdn settings change' );
}
}
/**
* Write (or remove) the Apache/LiteSpeed font-CORS block.
*
* nginx hosts get the same directives through nginx_directives() and the
* unified server-block snippet instead — we can't write their config.
*/
public function sync_font_cors(): void {
if ( ! class_exists( '\\XSpeed\\Server' ) || ! \XSpeed\Server::supports_htaccess() ) {
return;
}
if ( ! function_exists( 'insert_with_markers' ) ) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
}
if ( ! function_exists( 'insert_with_markers' ) ) {
return;
}
$opts = $this->get_settings();
$active = ! empty( $opts['enabled'] ) && ! empty( $opts['cdn_url'] );
$rules = $active
? array(
'',
' # Allow the CDN to pull webfonts cross-origin.',
' ',
' Header always set Access-Control-Allow-Origin "*"',
' ',
'',
)
: array();
// ABSPATH rather than get_home_path(): that function lives in
// wp-admin/includes/file.php, which is not loaded on a REST, CLI or
// cron request — and because this class is namespaced, the
// unqualified call resolved to XSpeed\Modules\Cdn\get_home_path()
// and fatalled on every real save, including disabling the module.
// This mirrors class-gzip.php, and the file_exists() guard it brings
// also stops insert_with_markers() creating a stray .htaccess at the
// WP root on a subdirectory install.
$htaccess = ABSPATH . '.htaccess';
if ( ! file_exists( $htaccess ) ) {
// Nothing to amend, and nothing to clean up.
if ( empty( $rules ) ) {
return;
}
if ( ! is_writable( ABSPATH ) ) {
return;
}
}
insert_with_markers( $htaccess, 'xSpeed CDN', $rules );
}
/**
* Font CORS for the origin.
*
* We ship the five font extensions enabled by default, and now that CSS
* actually reaches the CDN, `@font-face` inside those stylesheets
* resolves against the CDN host too. A font fetched cross-origin is a
* CORS request: without `Access-Control-Allow-Origin` on the ORIGIN
* response, the CDN caches a response the browser then refuses, and every
* webfont silently falls back to a system face.
*
* This was latent before — nothing reached the CDN, so nothing broke.
* Fixing the rewrite without this would turn a dead setting into a live
* regression, which is why it ships in the same change.
*
* @return string|null nginx directives, or null when the CDN is off.
*/
public function nginx_directives(): ?string {
$opts = $this->get_settings();
if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
return null;
}
return "# Allow the CDN to pull webfonts cross-origin.\n"
. "location ~* \\.(woff2?|ttf|otf|eot)$ {\n"
. " add_header Access-Control-Allow-Origin \"*\" always;\n"
. "}";
}
/**
* Emit a preconnect hint for the CDN host.
*
* @param array $hints URLs for this relation type.
* @param string $relation_type One of dns-prefetch / preconnect / …
* @return array
*/
public function add_preconnect( $hints, $relation_type ) {
if ( 'preconnect' !== $relation_type || ! is_array( $hints ) ) {
return $hints;
}
if ( Cdn_Rewriter::is_dev_host() ) {
return $hints;
}
$opts = $this->get_settings();
$host = Cdn_Rewriter::normalize_host( (string) ( $opts['cdn_url'] ?? '' ) );
if ( '' === $host ) {
return $hints;
}
// crossorigin so the hint also warms the connection fonts will use —
// font requests are CORS requests and would otherwise open a second
// connection.
$hints[] = array(
'href' => '//' . $host,
'crossorigin' => 'anonymous',
);
return $hints;
}
public function rewrite_attachment_url( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return $url;
}
return Cdn_Rewriter::rewrite_url( $url, $this->get_settings() );
}
/**
* pre_update_option filter — strips https:// + trailing slash from
* cdn_url before storage, so we always work against a bare host.
*
* @param mixed $value
* @return mixed
*/
public function normalize_on_save( $value ) {
if ( ! is_array( $value ) ) {
return $value;
}
if ( isset( $value['cdn_url'] ) ) {
$value['cdn_url'] = Cdn_Rewriter::normalize_host( (string) $value['cdn_url'] );
}
return $value;
}
public function cli_commands(): array {
return array(
array(
'name' => 'xspeed cdn',
'callback' => array( $this, 'cli_handler' ),
'shortdesc' => 'Show CDN settings + test rewriting a URL.',
'synopsis' => array(
array(
'type' => 'positional',
'name' => 'action',
'options' => array( 'status', 'test' ),
'optional' => true,
),
array(
'type' => 'assoc',
'name' => 'url',
'optional' => true,
),
),
),
);
}
public function cli_handler( array $args, array $assoc ): void {
$action = $args[0] ?? 'status';
$opts = $this->get_settings();
if ( 'test' === $action ) {
$url = isset( $assoc['url'] ) ? (string) $assoc['url'] : '';
if ( '' === $url ) {
\WP_CLI::error( 'Pass --url= to test rewriting.' );
}
Cdn_Rewriter::reset_state();
\WP_CLI::log( 'in: ' . $url );
\WP_CLI::log( 'out: ' . Cdn_Rewriter::rewrite_url( $url, $opts ) );
return;
}
\WP_CLI::log( sprintf( '%-22s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
\WP_CLI::log( sprintf( '%-22s %s', 'cdn_url', (string) ( $opts['cdn_url'] ?? '' ) ) );
\WP_CLI::log( sprintf( '%-22s %s', 'included_extensions', implode( ',', (array) ( $opts['included_extensions'] ?? array() ) ) ) );
\WP_CLI::log( sprintf( '%-22s %s', 'excluded_patterns', implode( ',', (array) ( $opts['excluded_patterns'] ?? array() ) ) ) );
}
}