com.beligum.core.utils.AssetPacker.java Source code

Java tutorial

Introduction

Here is the source code for com.beligum.core.utils.AssetPacker.java

Source

/*******************************************************************************
 * Copyright (c) 2013 by Beligum b.v.b.a. (http://www.beligum.com)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * Contributors:
 *     Beligum - initial implementation
 *******************************************************************************/
package com.beligum.core.utils;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import org.apache.commons.io.FileUtils;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.EvaluatorException;

import play.Logger;
import play.Play;
import play.mvc.Controller;
import play.mvc.Result;

import com.yahoo.platform.yui.compressor.CssCompressor;
import com.yahoo.platform.yui.compressor.JavaScriptCompressor;

public class AssetPacker extends Controller {
    //-----CONSTANTS-----
    private static final String CACHE_KEY = "pack";

    public static final String LOCAL_ASSETS_DIR = "public";
    public static final String LOCAL_RESOURCE_CACHE_DIR = "cache";
    //TODO: replace with dynamic path
    private static final String LOCAL_MANAGED_RESOURCE_DIR = "/target/scala-" + getScalaBinaryVersion()
            + "/resource_managed/main/" + LOCAL_ASSETS_DIR;

    private static final String PUBLIC_CACHE_DIR = "cache";

    private static final String CSS_TAG_TEMPLATE = "<link rel=\"stylesheet\" media=\"screen\" href=\"%s\" />";
    private static final String JS_TAG_TEMPLATE = "<script type=\"text/javascript\" src=\"%s\"></script>";

    private static final boolean FORCE_PACK = false;
    private static final boolean FORCE_MINIFY = false;

    //-----VARIABLES-----

    //-----CONSTRUCTORS-----
    public AssetPacker() {
    }

    //-----PUBLIC FUNCTIONS-----
    public static String pack(String[] files) {
        final String FILENAME_SUFFIX = "";

        String retVal = null;

        try {
            //if not supplied, look it up if nothing is forced
            boolean pack = !Play.isDev() || FORCE_PACK;
            boolean minify = !Play.isDev() || FORCE_MINIFY;

            if (files != null) {
                if (!pack) {
                    StringBuffer originalTags = new StringBuffer();
                    for (int i = 0; i < files.length; i++) {
                        if (files[i].endsWith(".css")) {
                            originalTags.append(String.format(CSS_TAG_TEMPLATE, getPublicUrl(files[i])) + "\n");
                        } else if (files[i].endsWith(".js")) {
                            originalTags.append(String.format(JS_TAG_TEMPLATE, getPublicUrl(files[i])) + "\n");
                        } else {
                            Logger.warn("Skipping asset file; " + files[i]);
                        }
                    }
                    retVal = originalTags.toString();
                } else if (files != null && files.length > 0) {
                    int cacheHashRaw = calcHash(files);
                    //TODO: maybe this isn't the best solution, but what are the odds?
                    if (cacheHashRaw < 0) {
                        cacheHashRaw += Integer.MAX_VALUE;
                    }
                    String cacheHash = "" + cacheHashRaw;

                    /*
                     * First, test the cache.
                     * Note: we use our own map to flush more easily 
                     */
                    Map<String, Object> cache = (Map<String, Object>) Cacher.fetchApplicationObject(CACHE_KEY);
                    if (cache == null) {
                        cache = new HashMap<String, Object>();
                        Cacher.storeApplicationObject(CACHE_KEY, cache);
                    }
                    /*
                     * Note: we currently have three cache-entries per hit:
                     * 
                     * - key = contains the html tags that point to the next (virtual) files
                     * - key+".css" = the content of the packed, minified and gzipped stylesheet files
                     * - key+".js" = the content of the packed, minified and gzipped javascript files
                     * 
                     * Here, we check if the plain key exists
                     */
                    String cacheValue = (String) cache.get(cacheHash);

                    //cache hit
                    if (cacheValue != null) {
                        retVal = cacheValue;
                    }

                    //cache miss
                    if (retVal == null) {
                        Logger.debug("Generating cache file for key: " + cacheHash);

                        List<String> jsFiles = new ArrayList<String>();
                        List<String> cssFiles = new ArrayList<String>();
                        for (String filename : files) {
                            if (filename.endsWith(".css")) {
                                cssFiles.add(filename);
                            } else if (filename.endsWith(".js")) {
                                jsFiles.add(filename);
                            } else {
                                Logger.error(
                                        "Can't pack this file, skipping. Note: I can only pack .js or .css files; "
                                                + filename);
                            }
                        }

                        /*
                         * Note: we switched from disk-based packing to memory-based caching.
                         * I've left the first solution commented out in the first block for future reference.
                         */
                        StringBuffer packedTags = new StringBuffer();
                        List<CachedPack> cachedPacks = new ArrayList<CachedPack>();
                        if (!cssFiles.isEmpty()) {
                            /*
                            File packFile = packFiles(cssFiles, FILENAME_PREFIX+cacheHash+FILENAME_SUFFIX+".css", minify);
                            String htmlTag = String.format(CSS_TAG_TEMPLATE, getPublicUrl(PUBLIC_CACHE_DIR+"/"+packFile.getName()));
                            retVal.append(htmlTag);
                            */

                            String key = cacheHash + ".css";
                            cache.put(key, new CachedPack(encodeGzip(stringPack(cssFiles, minify)), "text/css"));
                            packedTags.append(
                                    String.format(CSS_TAG_TEMPLATE, getPublicUrl(PUBLIC_CACHE_DIR + "/" + key)));
                        }
                        if (!jsFiles.isEmpty()) {
                            String key = cacheHash + ".js";
                            cache.put(key, new CachedPack(encodeGzip(stringPack(jsFiles, minify)),
                                    "application/javascript"));
                            packedTags.append(
                                    String.format(JS_TAG_TEMPLATE, getPublicUrl(PUBLIC_CACHE_DIR + "/" + key)));
                        }

                        /*
                         * Note: we return the path to virtual files, also stored in the application cache. See servePackFile() and the routes file for further details
                         */
                        retVal = packedTags.toString();
                        //naked key, without extension = html tags
                        cache.put(cacheHash, retVal);
                    }
                }
            }
        } catch (Exception e) {
            Logger.error("Error while packing files", e);
        }

        return retVal;
    }

    public static Result servePackFile(String file) {
        /*
         * Old disk-based solution
         * return ok(play.api.Play.getFile("public/cache/"+file, play.api.Play.current()));
         */

        try {
            //note: the (virtual) 'file' is used as the cache key, see pack()
            Map<String, Object> cache = (Map<String, Object>) Cacher.fetchApplicationObject(CACHE_KEY);
            CachedPack pack = null;
            if (cache != null && (pack = (CachedPack) cache.get(file)) != null) {
                /*
                 * TODO: the packed files are stored gzipped to save memory and we return it as raw bytes,
                 * but we should probably offer an alternative for browsers that don't support gzip?
                 */
                response().setHeader("Content-Encoding", "gzip");
                response().setHeader("Content-Length", pack.content.length + "");
                return ok(pack.content).as(pack.mimeType);
            } else {
                /*
                 * Flush the cache; this shouldn't happen if some client doens't want to load random asset files.
                 * Note: this can impact the performance, so I'm flagging this as 'error' so it pops up in the logs if it does. 
                 */
                Logger.error("Flushing the pack-cache, because we seem to miss an entry for " + file
                        + " - this shouldn't happen!");
                Cacher.storeApplicationObject(CACHE_KEY, null);
                return notFound();
            }
        } catch (Exception e) {
            Logger.error("Error while serving pack file", e);
            return internalServerError();
        }
    }

    private static class CachedPack {
        public byte[] content;
        public String mimeType;

        public CachedPack(byte[] content, String mimeType) {
            this.content = content;
            this.mimeType = mimeType;
        }
    }

    private static String stringPack(List<String> files, boolean minify) {
        StringBuilder retVal = new StringBuilder();

        int counter = 0;
        if (files != null) {
            for (String filename : files) {
                //first try the managed, parsed version
                File file = play.api.Play.getFile(LOCAL_MANAGED_RESOURCE_DIR + "/" + filename,
                        play.api.Play.current());
                //fall back to the public version
                if (file == null || !file.exists()) {
                    file = play.api.Play.getFile(LOCAL_ASSETS_DIR + "/" + filename, play.api.Play.current());
                }

                if (file != null && file.exists()) {
                    try {
                        /*
                         *  If it's not already minified, try to minify it here
                         *  Note: we could also search for the existing minified version of this file, but we ran into problems with it 
                         */
                        /*
                        if (!isMinifiedJs(file)) {
                            Logger.debug("Minifying file before packing it; "+file.getAbsolutePath());
                            retVal.append(minifyJs(file));
                        }
                        else {
                            retVal.append(FileUtils.readFileToString(file));
                        }
                        */

                        retVal.append(minify ? minify(file) : FileUtils.readFileToString(file));
                        //don't delete this
                        retVal.append("\n");
                        counter++;
                    } catch (Exception e) {
                        Logger.error("Error while reading file during aggregation: " + filename, e);
                    }
                } else {
                    Logger.warn("Supplied a file to pack, but it doesn't exist on the server: " + filename + " ("
                            + file.getAbsolutePath() + ")");
                }
            }
        }

        //Logger.debug("Aggregated "+counter+" files");

        /*
         * Note: you have to wrap this string in a @Html() tag on the template site
         */
        return retVal.toString();
    }

    private static int calcHash(String[] strings) {
        final int prime = 31;
        int result = 1;
        for (String s : strings) {
            result = result * prime + s.hashCode();
        }

        return result;
    }

    private static File testMinifiedJs(File file) {
        File retVal = file;
        if (file != null && file.exists() && file.getAbsolutePath().endsWith(".js")
                && !file.getAbsolutePath().endsWith(".min.js")) {
            File minFile = new File(
                    file.getAbsolutePath().substring(0, file.getAbsolutePath().length() - ".js".length())
                            + ".min.js");
            if (minFile.exists()) {
                retVal = minFile;
            }
        }

        return retVal;
    }

    private static File packFiles(List<String> files, String filename, boolean minify) throws IOException {
        File packFile = play.api.Play.getFile(LOCAL_ASSETS_DIR + "/" + LOCAL_RESOURCE_CACHE_DIR + "/" + filename,
                play.api.Play.current());
        packFile.mkdirs();
        if (packFile.exists()) {
            packFile.delete();
        }

        FileUtils.writeStringToFile(packFile, stringPack(files, minify));

        return packFile;
    }

    private static String minify(File file) {
        String retVal = "";

        StringReader in = null;
        StringWriter out = null;
        try {
            retVal = FileUtils.readFileToString(file);

            /*
             * Pre-processing
             */

            //remove 'special comments' so they get removed anyway (for libraries  la bootstrap, jquery,...)
            retVal = retVal.replace("/*!", "/*");

            in = new StringReader(retVal);
            out = new StringWriter();

            if (file != null && file.exists()) {

                if (file.getAbsolutePath().endsWith(".js")) {
                    JavaScriptCompressor compressor = new JavaScriptCompressor(in, new ErrorReporter() {
                        public void warning(String message, String sourceName, int line, String lineSource,
                                int lineOffset) {
                            Logger.warn(message);
                        }

                        public void error(String message, String sourceName, int line, String lineSource,
                                int lineOffset) {
                            Logger.error(message);
                        }

                        public EvaluatorException runtimeError(String message, String sourceName, int line,
                                String lineSource, int lineOffset) {
                            error(message, sourceName, line, lineSource, lineOffset);
                            return new EvaluatorException(message);
                        }
                    });

                    /*
                     * Display informational messages and warnings.
                     */
                    boolean verbose = false;

                    /*
                     * This minifies AND obfuscates local symbols, disable to minify only. 
                     */
                    boolean munge = true;

                    /* Preserve unnecessary semicolons (such as right before a '}') This option
                     * is useful when compressed code has to be run through JSLint (which is the
                     * case of YUI for example)
                     */
                    boolean preserveAllSemiColons = false;

                    /*
                     * Disable all the built-in micro optimizations.
                     */
                    boolean disableOptimizations = false;

                    compressor.compress(out, -1, munge, verbose, preserveAllSemiColons, disableOptimizations);
                    retVal = out.toString();

                    /*
                     * For Google Closure, switched to YuiCompressor cause it also provided css support
                     * 
                    com.google.javascript.jscomp.Compiler compiler = new com.google.javascript.jscomp.Compiler();
                    CompilerOptions options = new CompilerOptions();
                        
                    CompilationLevel.SIMPLE_OPTIMIZATIONS.setOptionsForCompilationLevel(options);
                    //WarningLevel.QUIET.setOptionsForWarningLevel(options);
                    //compiler.setLoggingLevel(Level.ALL);
                        
                    //options.setLanguageIn(CompilerOptions.LanguageMode.ECMASCRIPT5);
                    //Processes goog.provide() and goog.require() calls
                    //options.closurePass = true;
                        
                    List<SourceFile> externs = new ArrayList<SourceFile>();
                    List<SourceFile> inputs = new ArrayList<SourceFile>();
                    inputs.add(SourceFile.fromFile(file));
                        
                    com.google.javascript.jscomp.Result compileResult = compiler.compile(externs, inputs, options);
                    if (compileResult.success) {
                    retVal = compiler.toSource();
                    }
                    else {
                    throw new Exception(compileResult.debugLog);
                    }
                    */
                } else if (file.getAbsolutePath().endsWith(".css")) {
                    File minFile = new File(
                            file.getAbsolutePath().substring(0, file.getAbsolutePath().length() - ".css".length())
                                    + ".min.css");
                    //we always re-minify, just to be sure...
                    CssCompressor cssCompressor = new CssCompressor(in);
                    cssCompressor.compress(out, -1);
                    retVal = out.toString();
                } else {
                    throw new Exception("Can't minify this file; unknown source type.");
                }
            } else {
                Logger.error("Trying to minify a file that doesn't exist: " + file.getAbsolutePath());
            }
        } catch (Exception e) {
            Logger.warn("Error while minifying file " + file.getAbsolutePath(), e);
        } finally {
            if (in != null) {
                in.close();
            }
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                }
            }
        }

        return retVal;
    }

    private static boolean isJs(File file) {
        return file != null && file.exists() && file.getAbsolutePath().endsWith(".js");
    }

    private static boolean isMinifiedJs(File file) {
        return isJs(file) && file.getAbsolutePath().endsWith(".min.js");
    }

    private static final String getScalaBinaryVersion() {
        try {
            String[] versions = play.core.PlayVersion.scalaVersion().split("\\.");
            return versions[0] + "." + versions[1];
        } catch (Exception e) {
            Logger.error("Error while building the target scala directory; all asset packing will fail");
        }

        return null;
    }

    private static String getPublicUrl(String filenameInAssetsDir) {
        //return com.beligum.core.controllers.routes.Assets.at(filenameInAssetsDir).url();
        return "/assets/" + filenameInAssetsDir;
    }

    private static byte[] encodeGzip(String content) throws IOException {
        byte[] retVal = null;

        ByteArrayOutputStream out = null;
        GZIPOutputStream gzip = null;
        try {
            out = new ByteArrayOutputStream();
            gzip = new GZIPOutputStream(out);
            gzip.write(content.getBytes("UTF-8"));
            gzip.close();
            out.close();

            retVal = out.toByteArray();
        } catch (Exception e) {
            if (gzip != null) {
                gzip.close();
            }
            if (out != null) {
                out.close();
            }
        }

        return retVal;
    }

    private static String decodeGzip(byte[] content) throws IOException {
        String retVal = null;

        ByteArrayInputStream in = null;
        GZIPInputStream gzip = null;
        try {
            in = new ByteArrayInputStream(content);
            gzip = new GZIPInputStream(in);

            BufferedReader reader = new BufferedReader(new InputStreamReader(gzip, "UTF-8"));
            StringBuilder sb = new StringBuilder();
            String s;
            while ((s = reader.readLine()) != null) {
                sb.append(s);
            }
            gzip.close();
            in.close();

            retVal = sb.toString();
        } catch (Exception e) {
            if (gzip != null) {
                gzip.close();
            }
            if (in != null) {
                in.close();
            }
        }

        return retVal;
    }

    //-----PROTECTED FUNCTIONS-----

    //-----PRIVATE FUNCTIONS-----
}