Mugo Web main content.

JavaScript and CSS caching best practices with ezjscore and eZ Publish

By: Peter Keung | October 20, 2015 | eZ Publish development tips and Site performance

A good caching system keeps elements in the cache as long as possible, but clears them as soon as the elements are updated. For content pages in eZ Publish and most content management systems, "purge-on-publish" features are well documented. When it comes to JavaScript and CSS files, there are usually different caching systems involved, and these are important to configure. Otherwise, you'll end up with the all-too-familiar problem of a broken front-end on deployments, where the only fix is to have users do a "hard refresh" (CTRL+F5) in the browser.

With eZ Publish (legacy), the ezjscore extension is critical for loading, concatenating, and minifying JS and CSS files. It places the generated JS and CSS files in the /var/[site_name]/cache/public/ folder.

You can instruct browsers to cache these files for a long time with an Apache configuration such as this:

<LocationMatch "^/var/[^/]+/cache/public/.*">
# Force ezjscore packer js/css files to be cached 90 days at the client side
ExpiresActive on
ExpiresDefault "now plus 90 days"
SetOutputFilter DEFLATE
Header merge "Cache-Control" public
</LocationMatch>

If you are using a CDN or reverse proxy (such as Varnish) you'll also want an equivalent rule so that the server- or edge-side caching layer keeps those files for a long time.

When you modify a JS or CSS file, you want users to get the changes immediately. If the same JS and CSS file names are referenced by your website pages, then you have to worry about clearing the cache at the CDN or reverse proxy level and also at the browser level. It is also not possible to force all users to clear their browser cache. Therefore, you can instruct ezjscore to generate new file names whenever the eZ Publish cache is cleared with this setting in an override of ezjscore.ini.append.php:

[Packer]
AppendLastModifiedTime=enabled

This will make the JS and CSS file names something like this, with a timestamp appended:

/var/mugo/cache/public/stylesheets/cba6be1d1c03cdc0990e4246102a7a21_1444940081_all.css

The additional complication is with cached pages that still reference the old JS and CSS file names. You don't want to have to clear your entire site's page caches (especially at the CDN or reverse proxy level) whenever a JS or CSS change is released, so you have to consider that a stale cached page is still relevant -- not a desirable situation, but sometimes necessary. Unfortunately, when you clear ezjscore's cache, this removes all old generated JS and CSS files and can break those old links. With some additional configuration, you can keep these old generated files (who have file names that don't clash with your new generated files) on your server a bit longer. To do so, you'll need a new PHP class that will only delete generated files that are older than a certain amount of days. This example works for eZ DFS cluster configurations.

(Note: an alternative is to have an even longer caching time for JS and CSS files at your CDN or reverse proxy, but it is riskier as you could have cache expire earlier than expected if, for example, you have to restart your reverse proxy.)

First, add the following configuration to an override of site.ini.append.php:

[Cache_ezjscore]
class=MugoJSCCacheManager
# maxage is the maximum age a packed ezjscore .css or .js is allowed to be
# This is used to preserve the packed files for the timespan indicated
# Use any time that will work well with strtotime, for example "90 days"
# Do not use a minus sign - one will be added by the code
maxage=90 days

Then, your new PHP class should have a function called clearCache() that does the following:

<?php
class MugoJSCCacheManager {

    /**
    * Expires the ezjscore public cache folder by deleting all files that are more than 90 days old
    * @param array $cacheItem
    */
    public static function clearCache(array $cacheItem) {
        $ini = eZINI::instance();
        if ($ini->hasVariable("Cache_ezjscore", "maxage")) {
            $maxAge = "-" . $ini->variable("Cache_ezjscore", "maxage");
        } else {
            $maxAge = "-90 days";
        }
        $maxAge = strtotime($maxAge);

        // Get the existing JavaScript and stylesheet files
        $clusterHandler = eZClusterFileHandler::instance();
        $existingFiles = $clusterHandler->getFileList( array( 'ezjscore' ) );

        $pathUpdate = eZSys::rootDir() . '/';
        $fileIni = eZINI::instance('file.ini');
        if ( $fileIni->hasVariable( "eZDFSClusteringSettings", "MountPointPath" ) ) {
            $pathUpdate .= $fileIni->variable( "eZDFSClusteringSettings", "MountPointPath" ) . '/';
        }

        // Loop through all the existing files
        foreach ($existingFiles as $file) {

            // Get the modified time for the file
            $fileTimestamp = filemtime($pathUpdate . $file);

            // If the timestamp is before than $maxAge
            if ($fileTimestamp < $maxAge) {
                eZClusterFileHandler::instance()->fileDelete( $file );
                unlink( $pathUpdate . $file );
            }
        }
    }
}

With that in place, you can be sure that visitors will always get a properly formatted page -- using the new JavaScript files and stylesheets, or a valid older set until all page caches have expired.