Categories
WordPress

CSS & JS Concatenation in WordPress

At WordPress.com VIP one of the features we have on our platform is automated concatenation of Javascript and CSS files when registered through the core WordPress wp_enqueue__*() functions.

We do this using the nginx-http-concat plugin:

This plugin was written to work with nginx, but the server running derrick.blog is Apache.  I’ve worked around this and have nginx-http-concat running fully in WordPress, with added caching.

The bulk of the plugin is this file, which does all of the work of caching and calling the nignx-http-concat plugin:

<?php
// phpcs:disable WordPress.VIP.SuperGlobalInputUsage.AccessDetected, WordPress.Security.ValidatedSanitizedInput, WordPress.VIP.FileSystemWritesDisallow, WordPress.VIP.RestrictedFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents, WordPress.WP.AlternativeFunctions.json_encode_json_encode
if ( isset( $_SERVER['REQUEST_URI'] ) && '/_static/' === substr( $_SERVER['REQUEST_URI'], 0, 9 ) ) {
	$cache_file      = WP_HTTP_CONCAT_CACHE . '/' . md5( $_SERVER['REQUEST_URI'] );
	$cache_file_meta = WP_HTTP_CONCAT_CACHE . '/meta-' . md5( $_SERVER['REQUEST_URI'] );

	if ( file_exists( $cache_file ) ) {
		if ( time() - filemtime( $cache_file ) > 2 * 3600 ) {
			// file older than 2 hours, delete cache.
			unlink( $cache_file );
			unlink( $cache_file_meta );
		} else {
			// file younger than 2 hours, return cache.
			if ( file_exists( $cache_file_meta ) ) {
				$meta = json_decode( file_get_contents( $cache_file_meta ) );
				if ( null !== $meta && isset( $meta->headers ) ) {
					foreach ( $meta->headers as $header ) {
						header( $header );
					}
				}
			}
			$etag = '"' . md5( file_get_contents( $cache_file ) ) . '"';

			ob_start( 'ob_gzhandler' );
			header( 'x-http-concat: cached' );
			header( 'Cache-Control: max-age=' . 31536000 );
			header( 'ETag: ' . $etag );

			if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
				if ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) < filemtime( $cache_file ) ) {
					header( 'HTTP/1.1 304 Not Modified' );
					exit;
				}
			}

			echo file_get_contents( $cache_file ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
			$output = ob_get_clean();
			echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
			die();
		}
	}
	ob_start( 'ob_gzhandler' );
	require_once 'nginx-http-concat/ngx-http-concat.php';

	$output = ob_get_clean();
	$etag  = '"' . md5( file_get_contents( $output ) ) . '"';
	$meta   = array(
		'headers' => headers_list(),
	);

	header( 'x-http-concat: uncached' );
	header( 'Cache-Control: max-age=' . 31536000 );
	header( 'ETag: ' . $etag );

	if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
		if ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) < filemtime( $cache_file ) ) {
			header( 'HTTP/1.1 304 Not Modified' );
			exit;
		}
	}

	file_put_contents( $cache_file, $output );
	file_put_contents( $cache_file_meta, json_encode( $meta ) );
	echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
	die();
}

This little bit of code in wp-config.php is what calls the above file, before WordPress even initializes, to make this as speedy as possible:

define( 'WP_HTTP_CONCAT_CACHE', dirname(__FILE__) . '/wp-content/cache/http-concat-cache' );
require_once dirname(__FILE__) . '/wp-content/mu-plugins/emrikol-defaults/config-nginx-http-concat.php';

Finally, in an mu-plugin these lines enable the nginx-http-concat plugin:

require_once( plugin_dir_path( __FILE__ ) . 'emrikol-defaults/nginx-http-concat/cssconcat.php' );
	require_once( plugin_dir_path( __FILE__ ) . 'emrikol-defaults/nginx-http-concat/jsconcat.php' );

All of this could definitely be packed into a legit plugin, and even leave room for other features, such as:

  • An admin UI for enabling/disabling under certain condition
  • A “clear cache” button
  • A cron event to regularly delete expired cache items

As it is now though, I’m just leaving it be to see how well it works.  Wish me luck 🙂

Leave a Reply