Simple single-file PHP script for combining JS / CSS assets

Combining Assets

One of the basic tenets of making a web-page load quickly is reducing the number of HTTP requests that your page makes. One very common way of doing this, is assuring that all of your CSS files are combined into one request (ideally in the <head> tag), and all of your javscript files are combined into one request (ideally at the bottom of the <body> tag).

The Solution

I’ve worked with several large sites which each had their own solutions, but recently I found myself needing to speed up a fairly simple site: Burndown for Trello.

To make things simple (and so that I’d never have to write this again), I made a StaticResources system which will allow me to put just one PHP script into the root directory of the site, and use a few simple calls to add files to the header or footer of the page. The script requires no setup, installation, or configuration to run correctly. However, it has some optional advanced settings (which we’ll discuss at the end).

Usage

Usage is very simple. Just make a call to add files to the system, so that they’re included in the header or footer. Both local and external files are added with the same kind of call.

StaticResources::addHeaderFile("css/GoogleWebfont_Tauri_Latin.css");

StaticResources::addFooterFile("jquery-ui.js");
StaticResources::addFooterFile("http://code.jquery.com/ui/1.9.1/jquery-ui.js");

After you’ve added the files, make sure that somewhere in your code you print the HTML which will contain all of the files that were added

// put this at the bottom of the HEAD tag
print StaticResources::getHeaderHtml();

// put this at the bottom of the BODY tag
print StaticResources::getFooterHtml();

Here’s an example file of how you’d include a bunch of JS / CSS using this StaticResources system. It obviously won’t work if you don’t create JS/CSS files in the locations referenced, but this is very simple code to show the system in action.

<?php

include 'static.php';

// You can add header files any time before the StaticResources::getHeaderHtml() call.
StaticResources::addHeaderFile("css/GoogleWebfont_Tauri_Latin.css");

// You can add footer files any time before the StaticResources::getHeaderHtml() call.
StaticResources::addFooterFile("jquery-1.9.1.js");

// For files that won't change (like a specific version of jQuery), it's often better to host it on your
// own server instead of making a separate HTTP request to the CDN.
StaticResources::addFooterFile("jquery-ui.js");

?><!DOCTYPE html> 
<html>
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
		<title>StaticResources example page
		<?php

		// You can add header files any time before the StaticResources::getHeaderHtml() call.
		StaticResources::addHeaderFile("jquery-ui.min.css");
		
		// A good place to output the HTML is right at the end of the HEAD tag.
		// getHeaderHtml() returns a string (to be more flexible with various PHP frameworks)
		// so be sure to print its result.
		print StaticResources::getHeaderHtml();
		?>
	
	<body>
		<header>
			Created for <a href='https://burndownfortrello.com'>Burndown for Trello.
		</header>

		<article>
			<h1>StaticResources example page

<p>This page is an example of loading static resources in combined files.

</article> <footer> © 2013 – Sean Colombo </footer> <?php // For files that won’t be changing StaticResources::addFooterFile(“stripeIntegration.js”); // To add a file from another site, just make sure to use the full URL. // Since there is no file extension, we have to pass in the filetype as a second parameter. StaticResources::addFooterFile(“https://www.google.com/jsapi”, “js”); // Output the footerHtml at the bottom of the page. It doesn’t have to be in the FOOTER tag, it should // be done as far down on the page as possible (close to the end of the BODY tag). print StaticResources::getFooterHtml(); ?> </body> </html>

The Code

Without further delay, this is the code of static.php that you should put in the main (eg: public_html) directory of your app.

\n";

		$html .= $this->myGetHtmlForArray( $this->_headerFilesByType );
		
		// Add the remote resources that were explicitly told to load AFTER the combined local file (by default, remote
		// files will be in the _headerFilesByType array and have been outputted before the combined local file).
		$html .= $this->myGetHtmlForArray( $this->_lateHeaderFilesByType );

		$_headerAlreadyReturned = true;
		return $html;
	} // end getHeaderHtml()

	/**
	 * Returns a string which contains the HTML that's needed to
	 * import the files at the bottom of the BODY tag.  This should be called exactly
	 * once (and it's results should be printed at the bottom of the  tag - right
	 * before ) on every page.
	 */
	public function myGetFooterHtml(){
		$html = "\n" . $this->myGetHtmlForArray( $this->_footerFilesByType );
		$_footerAlreadyReturned = true;
		return $html;
	} // end getFooterHtml()

	/**
	 * Given an associative array of static resources whose keys are
	 * resource types and whose values are arrays of fileNames (local
	 * and/or remote), this will return a string which contains the
	 * HTML that's needed to import those files.
	 *
	 * NOTE: External files are always imported before the combined
	 * local-file if they are in the same array. To avoid this, use parameters
	 * to addHeaderFile() that specify addRemoteAfterLocalFiles=true and the
	 * external file will be put into an entirely separate array.
	 */
	public function myGetHtmlForArray($filesByType){
		global $staticPhp_cacheBuster;
		$html = "";

		// The URL of this script (which will act as an endpoint and serve up the actual content.
		$url = $this->staticResourcesRootUrl . basename(__FILE__);
		foreach($filesByType as $fileType => $files){
			$localFiles = array();

			// Include all external files first (way more likely that local files depend on remote than vice-versa).
			foreach($files as $fileName){
				if(StaticResources::isRemoteFile($fileName)){
					// Add the HTML for including the remote file.
					if($fileType == "css"){
						$html .= "		\n";
					} else if($fileType == "js"){
						$html .= "		\n";
					} else {
						// Each file type needs to be included a certain way, and we don't recognize this fileType.
						$errorString = sprintf(self::ERR_CANT_OUTPUT_FILETYPE, htmlentities($fileType));
						trigger_error($errorString, E_USER_WARNING);
					}
				} else {
					$localFiles[] = $fileName;
				}
			}

			// Output the HTML which makes the request for the combined-file of all local files of the same fileType.
			if(count($localFiles) > 0){
			
				// TODO: TWEAK SO THAT THE DELIMITER ISN'T URL-ENCODED. MAKES IT MORE READABLE AND SHORTER.
				// TODO: TWEAK SO THAT THE DELIMITER ISN'T URL-ENCODED. MAKES IT MORE READABLE AND SHORTER.

				$fullUrl = $url . "?fileType={$fileType}&files=".rawurlencode( implode(STATICRESOURCES_FILE_DELIMITER, $localFiles) );
				$fullUrl .= (empty($staticPhp_cacheBuster)? "" : "?cb=$staticPhp_cacheBuster");

				if($fileType == "css"){
					$html .= "		\n";
				} else if($fileType == "js"){
					$html .= "		\n";
				} else {
					// Each file type needs to be included a certain way, and we don't recognize this fileType.
					$errorString = sprintf(self::ERR_CANT_OUTPUT_FILETYPE, htmlentities($fileType));
					trigger_error($errorString, E_USER_WARNING);
				}
			}
		}

		return $html;
	} // end myGetHtmlForArray()

	/**
	 * Returns true if the given fileName is allowed to be included, false otherwise.
	 * The reason a file may not be allowed is that it's of the wrong file-type. One
	 * reason for this is that we don't want attackers to request file types that may
	 * contain password-files or source code that some users of this script might not
	 * want to make public, etc..
	 */
	private function fileIsAllowed($fileName){
		$fileIsAllowed = true;
		if( !StaticResources::isRemoteFile($fileName)){
			$fileExtension = strtolower( StaticResources::getFileExtension($fileName) );
			$fileIsAllowed = in_array($fileExtension, self::$ALLOWED_FILE_TYPES);
		}
		return $fileIsAllowed;
	} // end fileIsAllowed()

	/**
	 * Returns true if the fileName is from another site and false if it is from this site.
	 */
	public static function isRemoteFile($fileName){
		// If it starts with a protocol (ftp://, http://, https://, etc.) then it is remote (not local).
		return (0 <  preg_match("/^[a-z0-9]+:\/\//i", $fileName));
	}

	/**
	 * If the 'fileName' is a local file, this will return that fileName in such a way
	 * that the fileName can be loaded from disk safely (without allowing the user to
	 * jump out of the current directory with "../" or absolute directories such
	 * as "/usr/bin/").
	 *
	 * WARNING: THIS SANITIZES IT AS A FILE-PATH BUT DOES NOT DO htmlentities()
	 * OR ANYTHING SIMILAR, SO IT DOES NOT SANITIZE FOR PRINTING INTO THE PAGE.
	 */
	public static function sanitizeFileName($fileName){
		// Only local files need to be sanitized.
		if( !StaticResources::isRemoteFile($fileName)){
			// Make sure the user can't get above the current directory using "../".
			while(strpos($fileName, "../") !== false){
				$fileName = str_replace("../", "", $fileName);
			}
			
			// Starting out with current directory avoids abusing absolute paths such as "/usr/bin"
			if(strpos($fileName, "./") !== 0){ // if path already starts with ./, don't duplicate it.
				if(strpos($fileName, "/") === 0){ // path already starts with "/", just turn it into "./".
					$fileName = ".$fileName";
				} else {
					$fileName = "./$fileName"; // all other paths that start 'normally' (not "./" or "/").
				}
			}
		}

		return $fileName;
	}

	////
	// Returns the file extension (without the leading "."). Example: "php".
	////
	public static function getFileExtension($fileName){
		// If there is a query-string, chop that off before looking for the extension.
		if(strpos($fileName, "?") !== false){ // getting the FIRST question-mark to prevent any shenannigans.
			$fileName = substr($fileName, 0, strpos($fileName, "?"));
		}
	
		return pathinfo($fileName, PATHINFO_EXTENSION);
	}


	// ----- STATIC HELPERS -----
	/**
	 * Gets a singleton object of the StaticResources type to make it easy for the
	 * script to use StaticResources throughout the web-app without passing the object
	 * around.  This is probably the most common use-case.
	 */
	public static function getSingleton(){
		global $staticResourcesSingleton;
		if(empty($staticResourcesSingleton)){
			$staticResourcesSingleton = new StaticResources();
		}
		return $staticResourcesSingleton;
	} // end getSingleton()
	
	public static function addHeaderFile($fileName, $fileType="", $addRemoteAfterLocalFiles=false, $warnIfLateAdded=true){
		$singleton = StaticResources::getSingleton();
		$singleton->myAddHeaderFile($fileName, $fileType, $addRemoteAfterLocalFiles, $warnIfLateAdded);
	}
	public static function addFooterFile($fileName, $fileType="", $warnIfLateAdded=true){
		$singleton = StaticResources::getSingleton();
		$singleton->myAddFooterFile($fileName, $fileType, $warnIfLateAdded);
	}
	public static function getHeaderHtml(){
		$singleton = StaticResources::getSingleton();
		return $singleton->myGetHeaderHtml();
	}
	public static function getFooterHtml(){
		$singleton = StaticResources::getSingleton();
		return $singleton->myGetFooterHtml();
	}
	public static function setRootUrl( $rootUrl ){
		$singleton = StaticResources::getSingleton();
		$singleton->mySetRootUrl($rootUrl);
	}
	/**
	 * Given an array of filetype -> (array of files) combos, will get the HTML for this file. This
	 * is NOT recommended in most uses, you should only be getting HTML from getHeaderHtml()
	 * and getFooterHtml() when possible. This is to allow you flexibility to break the rules
	 * if needed. For example: if you want to output a specific local file above the remote files
	 * and NOT bundled with the other local files (due to dependencies) then you can accomplish this.
	 */
	public static function getHtmlForArray( $fileArray ){
		$singleton = StaticResources::getSingleton();
		return $singleton->myGetHtmlForArray($fileArray);
	}

} // end class StaticResources

Using this script with a CDN

One drawback of serving a combined file is that your server has to read in all of the files, then print them all. This makes the call slightly slower than it has to be. One solution is to use a CDN to cache the files on your site. This way, even if it takes your server a second or so to generate the file the first time, it can be served to the next user instantly, from the CDN.

For Burndown for Trello, we use Fastly as our CDN (disclosure: my fiance works there, but we use it because it’s free for one backend and it’s crazy fast).

The code to make this work with a CDN is very simple: one extra line below the include call:

include 'static.php';
StaticResources::setRootUrl("http://b4t.global.ssl.fastly.net/");

Code minification

Another common performance trick is to use a minifier to reduce file-size. This hasn’t been added to the script yet, but may come in the future. If you have a favorite minifier, I’ve conspicuously commented in the code where it would make sense to run the minifier.

Minification takes a little while to run, so it’s highly recommended that you get a CDN if you’re using minificaiton.

The End

Hope you found the script useful. If you use it on your site, please link to it in the comments!
Thanks,
– Sean Colombo

5 Responses to Simple single-file PHP script for combining JS / CSS assets

  1. Dude….

    Why are you invoking PHP for static files – this is really not a good idea for performance.

    You should bundle once and include that bundle as a static css/js file which can be cached efficiently and more importantly doesn’t need PHP to be invoked to serve

    • @Alex: great question! 🙂

      Combining assets during a build process is a valid approach. There are a couple of reasons it’s not very popular though.

      The most significant reason, is that there really isn’t a site-size that it makes sense for. Small PHP sites typically don’t have a very formalized build/push process. At the point that a site becomes large enough for build-steps, the site typically would have a CDN (more on CDNs is in the post). Setting up a CDN is almost certainly quicker than creating an asset-compilation system and makes back-end latency of the PHP script pretty negligible. If your site is small, you can usually get the service for free (eg: I don’t pay for Fastly at the moment because I only use one backend and that qualifies for a free Developer account).

      To put it in perspective, the script is cached for one month (see lines 129-130). Once you get a CDN set up, that means that you’ll hit the PHP backend about once every 30 days (or once every time you push code which updates a cache-busting variable). That one hit will add somewhere in the ballpark of 100ms to 300ms. As your site grows, you’d probably want to also add Minification to the PHP script which would add another couple hundred milliseconds. So you’d have about a half a second extra, on one request per push or per month.

      When I was at Wikia, we were serving just over one billion page-views per month. So even though we had plenty of engineering resources to add something to our build-step which would hit the script (therefore warming the CDN’s cache), the worst-case impact was around 0.5s for one user per push, and the average impact was something like ~0.0000005 milliseconds, so we never got around to it.

      So, to circle back: if you want to combine assets, you could certainly do a build-script instead… but I’d recommend going the easier route and just using this script (or something similar) with a CDN configured.

      I can’t picture any scenarios (due to privacy/security/technical constraints?) where a site couldn’t use a CDN, but if you were in that kind of scenario for some reason – a build script would then make more sense than using a script like this one.

      Thanks again for the question/suggestion! 🙂

    • Hi Robert,
      Cache headers are a very important part of performance! 🙂 While the best way to set them has been evolving a bit, one of the best current methods is to set a far-future “Expires” date (for example: a month). This will cause the browser to not even check the server in subsequent calls for a month (so you don’t have to waste the round-trip just to get the “304: Not Modified”).

      In the code above, we set the Expiration caching header it in these lines:

      $SECONDS_IN_MONTH = 60*60*24*30;
      header(‘Expires: ‘.gmdate(‘D, d M Y H:i:s \G\M\T’, time() + $SECONDS_IN_MONTH)); // needs to be after session_start if session_start is used.

      Although I didn’t mention it in this post, a very common method of bypassing that caching when you push changes, is to have a site-wide cache-buster for your assets, so you’d have a file somewhere with a cachebuster value in it, like:

      $cacheBuster = “20160304_00”;

      Then on each line that you include an asset, you would append the cachebuster:

      global $cacheBuster;

      StaticResources::addFooterFile(“http://code.jquery.com/ui/1.9.1/jquery-ui.js?cb=$cacheBuster”);

      This is a pretty simple method of purging, and it’s the one I usually use. There are other methods now that CDNs are offering instant purging APIs.

      I hope that answers your question 🙂 please let me know if there’s anything you’d like me to clarify!

Leave a Reply

Your email address will not be published. Required fields are marked *