You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@tapestry.apache.org by hl...@apache.org on 2013/03/15 00:09:18 UTC

[9/14] git commit: Add content checksum to URL for combined content of JavaScript stacks

Add content checksum to URL for combined content of JavaScript stacks


Project: http://git-wip-us.apache.org/repos/asf/tapestry-5/repo
Commit: http://git-wip-us.apache.org/repos/asf/tapestry-5/commit/373f5cf5
Tree: http://git-wip-us.apache.org/repos/asf/tapestry-5/tree/373f5cf5
Diff: http://git-wip-us.apache.org/repos/asf/tapestry-5/diff/373f5cf5

Branch: refs/heads/master
Commit: 373f5cf52a5a8c6b65a273c25815c399e75060ec
Parents: ceb220e
Author: Howard M. Lewis Ship <hl...@apache.org>
Authored: Thu Mar 14 11:34:07 2013 -0700
Committer: Howard M. Lewis Ship <hl...@apache.org>
Committed: Thu Mar 14 13:41:12 2013 -0700

----------------------------------------------------------------------
 .../services/ClasspathAssetAliasManagerImpl.java   |   11 +-
 .../internal/services/ContextAssetFactory.java     |   20 ++-
 .../internal/services/ResourceStreamerImpl.java    |    4 +-
 .../services/assets/AssetPathConstructorImpl.java  |   34 +++-
 .../services/assets/JavaScriptStackAssembler.java  |   39 ++++
 .../assets/JavaScriptStackAssemblerImpl.java       |  159 ++++++++++++++
 .../services/assets/StackAssetRequestHandler.java  |  170 ++------------
 .../JavaScriptStackPathConstructorImpl.java        |   40 +++-
 .../services/assets/AssetPathConstructor.java      |   20 ++-
 .../tapestry5/services/assets/AssetsModule.java    |    1 +
 .../tapestry5/integration/app1/LibraryTests.groovy |    6 +-
 .../internal/services/ContextAssetFactoryTest.java |    4 +-
 12 files changed, 332 insertions(+), 176 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClasspathAssetAliasManagerImpl.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClasspathAssetAliasManagerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClasspathAssetAliasManagerImpl.java
index 69cb15c..7a04a87 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClasspathAssetAliasManagerImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClasspathAssetAliasManagerImpl.java
@@ -16,11 +16,13 @@ package org.apache.tapestry5.internal.services;
 
 import org.apache.tapestry5.ioc.Resource;
 import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
+import org.apache.tapestry5.ioc.internal.util.InternalUtils;
 import org.apache.tapestry5.ioc.util.AvailableValues;
 import org.apache.tapestry5.ioc.util.UnknownValueException;
 import org.apache.tapestry5.services.ClasspathAssetAliasManager;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
 
+import java.io.IOException;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
@@ -103,7 +105,14 @@ public class ClasspathAssetAliasManagerImpl implements ClasspathAssetAliasManage
 
                 String virtualPath = resourcePath.substring(pathPrefix.length() + 1);
 
-                return assetPathConstructor.constructAssetPath(virtualFolder, virtualPath, resource);
+                try
+                {
+                    return assetPathConstructor.constructAssetPath(virtualFolder, virtualPath, resource);
+                } catch (IOException ex)
+                {
+                    throw new RuntimeException(String.format("Unable to construct asset path for %s/%s (from %s): %s",
+                            virtualFolder, virtualPath, resource, InternalUtils.toMessage(ex)), ex);
+                }
             }
         }
 

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ContextAssetFactory.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ContextAssetFactory.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ContextAssetFactory.java
index b7e41e2..bd6c6b3 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ContextAssetFactory.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ContextAssetFactory.java
@@ -16,11 +16,14 @@ package org.apache.tapestry5.internal.services;
 
 import org.apache.tapestry5.Asset;
 import org.apache.tapestry5.ioc.Resource;
+import org.apache.tapestry5.ioc.internal.util.InternalUtils;
 import org.apache.tapestry5.services.AssetFactory;
 import org.apache.tapestry5.services.AssetPathConverter;
 import org.apache.tapestry5.services.Context;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
 
+import java.io.IOException;
+
 /**
  * Implementation of {@link AssetFactory} for assets that are part of the web application context.
  *
@@ -49,14 +52,21 @@ public class ContextAssetFactory implements AssetFactory
 
     public Asset createAsset(Resource resource)
     {
-        String defaultPath = assetPathConstructor.constructAssetPath(RequestConstants.CONTEXT_FOLDER, resource.getPath(), resource);
+        try
+        {
+            String defaultPath = assetPathConstructor.constructAssetPath(RequestConstants.CONTEXT_FOLDER, resource.getPath(), resource);
+
+            if (invariant)
+            {
+                return createInvariantAsset(resource, defaultPath);
+            }
 
-        if (invariant)
+            return createVariantAsset(resource, defaultPath);
+        } catch (IOException ex)
         {
-            return createInvariantAsset(resource, defaultPath);
+            throw new RuntimeException(String.format("Unable to construct asset path for %s: %s",
+                    resource, InternalUtils.toMessage(ex)), ex);
         }
-
-        return createVariantAsset(resource, defaultPath);
     }
 
     private Asset createInvariantAsset(final Resource resource, final String defaultPath)

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
index 04c2a65..c4a6464 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
@@ -62,7 +62,9 @@ public class ResourceStreamerImpl implements ResourceStreamer
                                 OperationTracker tracker,
 
                                 @Symbol(SymbolConstants.PRODUCTION_MODE)
-                                boolean productionMode, ResourceChangeTracker resourceChangeTracker)
+                                boolean productionMode,
+
+                                ResourceChangeTracker resourceChangeTracker)
     {
         this.request = request;
         this.response = response;

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/AssetPathConstructorImpl.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/AssetPathConstructorImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/AssetPathConstructorImpl.java
index 159c854..6ba0d72 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/AssetPathConstructorImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/AssetPathConstructorImpl.java
@@ -15,6 +15,7 @@
 package org.apache.tapestry5.internal.services.assets;
 
 import org.apache.tapestry5.SymbolConstants;
+import org.apache.tapestry5.internal.services.RequestConstants;
 import org.apache.tapestry5.ioc.Resource;
 import org.apache.tapestry5.ioc.annotations.Symbol;
 import org.apache.tapestry5.ioc.internal.util.InternalUtils;
@@ -23,6 +24,7 @@ import org.apache.tapestry5.services.PathConstructor;
 import org.apache.tapestry5.services.Request;
 import org.apache.tapestry5.services.assets.AssetChecksumGenerator;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
+import org.apache.tapestry5.services.assets.StreamableResource;
 
 import java.io.IOException;
 
@@ -60,7 +62,26 @@ public class AssetPathConstructorImpl implements AssetPathConstructor
         prefix = pathConstructor.constructClientPath(assetPathPrefix, "");
     }
 
-    public String constructAssetPath(String virtualFolder, String path, Resource resource)
+    public String constructStackAssetPath(String localeName, String path, StreamableResource resource) throws IOException
+    {
+        StringBuilder builder = new StringBuilder();
+
+        if (fullyQualified)
+        {
+            builder.append(baseURLSource.getBaseURL(request.isSecure()));
+        }
+
+        builder.append(prefix);  // ends with a slash
+
+        builder.append(RequestConstants.STACK_FOLDER).append("/");
+        builder.append(assetChecksumGenerator.generateChecksum(resource)).append("/");
+        builder.append(localeName).append("/");
+        builder.append(path);
+
+        return builder.toString();
+    }
+
+    public String constructAssetPath(String virtualFolder, String path, Resource resource) throws IOException
     {
         assert InternalUtils.isNonBlank(virtualFolder);
         assert path != null;
@@ -76,14 +97,11 @@ public class AssetPathConstructorImpl implements AssetPathConstructor
         builder.append(virtualFolder);
         builder.append("/");
 
-        try
-        {
-            builder.append(assetChecksumGenerator.generateChecksum(resource));
-        } catch (IOException ex)
-        {
-            throw new RuntimeException(ex);
-        }
+        builder.append(assetChecksumGenerator.generateChecksum(resource));
 
+        // TODO: Under what conditions would the path ever be blank? Is this allowed? It may have made sense
+        // in 5.3, to allow access to a folder (to allow the client to build relative URLs), but that
+        // is no longer true in 5.4 because of the checksum in the URL.
         if (InternalUtils.isNonBlank(path))
         {
             builder.append('/');

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssembler.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssembler.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssembler.java
new file mode 100644
index 0000000..d63d71c
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssembler.java
@@ -0,0 +1,39 @@
+// Copyright 2013 The Apache Software Foundation
+//
+// 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.
+
+package org.apache.tapestry5.internal.services.assets;
+
+import org.apache.tapestry5.services.assets.StreamableResource;
+
+import java.io.IOException;
+
+/**
+ * Assembles the individual assets of a {@link org.apache.tapestry5.services.javascript.JavaScriptStack} into
+ * a single resource; this is needed to generate a checksum for the aggregated assets, and also to service the
+ * aggregated stack content.
+ *
+ * @see org.apache.tapestry5.SymbolConstants#COMBINE_SCRIPTS
+ * @since 5.4
+ */
+public interface JavaScriptStackAssembler
+{
+    /**
+     * Obtains th {@link org.apache.tapestry5.services.javascript.JavaScriptStack} by name, and then
+     * uses the {@link org.apache.tapestry5.services.assets.StreamableResourceSource} service to
+     * obtain the assets, which are combined togethers.
+     * <p/>
+     * Expects the {@linkplain org.apache.tapestry5.services.LocalizationSetter#setNonPersistentLocaleFromLocaleName(String) non-persistent locale} to be set before invoking!
+     */
+    StreamableResource assembleJavaScriptResourceForStack(String stackName, boolean compress) throws IOException;
+}

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssemblerImpl.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssemblerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssemblerImpl.java
new file mode 100644
index 0000000..d84bb28
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/JavaScriptStackAssemblerImpl.java
@@ -0,0 +1,159 @@
+// Copyright 2013 The Apache Software Foundation
+//
+// 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.
+
+package org.apache.tapestry5.internal.services.assets;
+
+import org.apache.tapestry5.Asset;
+import org.apache.tapestry5.ioc.Resource;
+import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
+import org.apache.tapestry5.ioc.services.ThreadLocale;
+import org.apache.tapestry5.json.JSONArray;
+import org.apache.tapestry5.services.assets.CompressionStatus;
+import org.apache.tapestry5.services.assets.StreamableResource;
+import org.apache.tapestry5.services.assets.StreamableResourceProcessing;
+import org.apache.tapestry5.services.assets.StreamableResourceSource;
+import org.apache.tapestry5.services.javascript.JavaScriptStack;
+import org.apache.tapestry5.services.javascript.JavaScriptStackSource;
+
+import java.io.*;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.zip.GZIPOutputStream;
+
+public class JavaScriptStackAssemblerImpl implements JavaScriptStackAssembler
+{
+    private static final String JAVASCRIPT_CONTENT_TYPE = "text/javascript";
+
+    private ThreadLocale threadLocale;
+
+    private final ResourceChangeTracker resourceChangeTracker;
+
+    private final StreamableResourceSource streamableResourceSource;
+
+    private final JavaScriptStackSource stackSource;
+
+    private final Map<String, StreamableResource> cache = CollectionFactory.newCaseInsensitiveMap();
+
+    // TODO: Support for minimization
+    // TODO: Support for aggregated CSS as well as aggregated JavaScript
+
+    public JavaScriptStackAssemblerImpl(ThreadLocale threadLocale, ResourceChangeTracker resourceChangeTracker, StreamableResourceSource streamableResourceSource, JavaScriptStackSource stackSource)
+    {
+        this.threadLocale = threadLocale;
+        this.resourceChangeTracker = resourceChangeTracker;
+        this.streamableResourceSource = streamableResourceSource;
+        this.stackSource = stackSource;
+
+        resourceChangeTracker.clearOnInvalidation(cache);
+    }
+
+    @Override
+    public StreamableResource assembleJavaScriptResourceForStack(String stackName, boolean compress) throws IOException
+    {
+        Locale locale = threadLocale.getLocale();
+
+        return assembleJavascriptResourceForStack(locale, stackName, compress);
+    }
+
+    private StreamableResource assembleJavascriptResourceForStack(Locale locale, String stackName, boolean compress) throws IOException
+    {
+        String key =
+                String.format("%s[%s] %s",
+                        stackName,
+                        compress ? "COMPRESS" : "UNCOMPRESSED",
+                        locale.toString());
+
+        StreamableResource result = cache.get(key);
+
+        if (result == null)
+        {
+            result = assemble(locale, stackName, compress);
+            cache.put(key, result);
+        }
+
+        return result;
+    }
+
+    private StreamableResource assemble(Locale locale, String stackName, boolean compress) throws IOException
+    {
+        if (compress)
+        {
+            return compressStream(assembleJavascriptResourceForStack(locale, stackName, false));
+        }
+
+        JavaScriptStack stack = stackSource.getStack(stackName);
+
+        return assemble(locale.toString(), stackName, stack.getJavaScriptLibraries());
+    }
+
+
+    private StreamableResource assemble(String localeName, String stackName, List<Asset> libraries) throws IOException
+    {
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+        OutputStreamWriter osw = new OutputStreamWriter(stream, "UTF-8");
+        PrintWriter writer = new PrintWriter(osw, true);
+        long lastModified = 0;
+
+        StringBuilder description = new StringBuilder(String.format("'%s' JavaScript stack, for locale %s, resources=", stackName, localeName));
+        String sep = "";
+
+        JSONArray paths = new JSONArray();
+
+        for (Asset library : libraries)
+        {
+            String path = library.toClientURL();
+
+            paths.put(path);
+
+            writer.format("\n/* %s */;\n", path);
+
+            Resource resource = library.getResource();
+
+            description.append(sep).append(resource.toString());
+            sep = ", ";
+
+            StreamableResource streamable = streamableResourceSource.getStreamableResource(resource,
+                    StreamableResourceProcessing.FOR_AGGREGATION, resourceChangeTracker);
+
+            streamable.streamTo(stream);
+
+            lastModified = Math.max(lastModified, streamable.getLastModified());
+        }
+
+        writer.close();
+
+        return new StreamableResourceImpl(
+                description.toString(),
+                JAVASCRIPT_CONTENT_TYPE, CompressionStatus.COMPRESSABLE, lastModified,
+                new BytestreamCache(stream));
+    }
+
+
+    private StreamableResource compressStream(StreamableResource uncompressed) throws IOException
+    {
+        ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+        OutputStream compressor = new BufferedOutputStream(new GZIPOutputStream(compressed));
+
+        uncompressed.streamTo(compressor);
+
+        compressor.close();
+
+        BytestreamCache cache = new BytestreamCache(compressed);
+
+        return new StreamableResourceImpl(uncompressed.getDescription(), JAVASCRIPT_CONTENT_TYPE, CompressionStatus.COMPRESSED,
+                uncompressed.getLastModified(), cache);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/StackAssetRequestHandler.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/StackAssetRequestHandler.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/StackAssetRequestHandler.java
index 987e8e5..31c6b08 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/StackAssetRequestHandler.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/StackAssetRequestHandler.java
@@ -14,83 +14,52 @@
 
 package org.apache.tapestry5.internal.services.assets;
 
-import org.apache.tapestry5.Asset;
 import org.apache.tapestry5.SymbolConstants;
 import org.apache.tapestry5.internal.services.ResourceStreamer;
 import org.apache.tapestry5.ioc.OperationTracker;
-import org.apache.tapestry5.ioc.Resource;
-import org.apache.tapestry5.ioc.annotations.PostInjection;
 import org.apache.tapestry5.ioc.annotations.Symbol;
-import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
-import org.apache.tapestry5.json.JSONArray;
 import org.apache.tapestry5.services.LocalizationSetter;
 import org.apache.tapestry5.services.Request;
 import org.apache.tapestry5.services.Response;
 import org.apache.tapestry5.services.ResponseCompressionAnalyzer;
-import org.apache.tapestry5.services.assets.*;
-import org.apache.tapestry5.services.javascript.JavaScriptStack;
+import org.apache.tapestry5.services.assets.AssetRequestHandler;
+import org.apache.tapestry5.services.assets.ResourceMinimizer;
+import org.apache.tapestry5.services.assets.StreamableResource;
+import org.apache.tapestry5.services.assets.StreamableResourceSource;
 import org.apache.tapestry5.services.javascript.JavaScriptStackSource;
 
-import java.io.*;
-import java.util.List;
-import java.util.Map;
+import java.io.IOException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import java.util.zip.GZIPOutputStream;
 
 public class StackAssetRequestHandler implements AssetRequestHandler
 {
-    private static final String JAVASCRIPT_CONTENT_TYPE = "text/javascript";
-
-    private final StreamableResourceSource streamableResourceSource;
-
-    private final JavaScriptStackSource javascriptStackSource;
-
     private final LocalizationSetter localizationSetter;
 
     private final ResponseCompressionAnalyzer compressionAnalyzer;
 
     private final ResourceStreamer resourceStreamer;
 
-    private final Pattern pathPattern = Pattern.compile("^(.+)/(.+)\\.js$");
-
-    // Two caches, keyed on extra path. Both are accessed only from synchronized blocks.
-    private final Map<String, StreamableResource> uncompressedCache = CollectionFactory.newCaseInsensitiveMap();
-
-    private final Map<String, StreamableResource> compressedCache = CollectionFactory.newCaseInsensitiveMap();
-
-    private final ResourceMinimizer resourceMinimizer;
+    // Group 1: checksum
+    // Group 2: locale
+    // Group 3: path
+    private final Pattern pathPattern = Pattern.compile("^(.+)/(.+)/(.+)\\.js$");
 
     private final OperationTracker tracker;
 
-    private final boolean minificationEnabled;
-
-    private final ResourceChangeTracker resourceChangeTracker;
+    private final JavaScriptStackAssembler javaScriptStackAssembler;
 
-    public StackAssetRequestHandler(StreamableResourceSource streamableResourceSource,
-                                    JavaScriptStackSource javascriptStackSource, LocalizationSetter localizationSetter,
-                                    ResponseCompressionAnalyzer compressionAnalyzer, ResourceStreamer resourceStreamer,
-                                    ResourceMinimizer resourceMinimizer, OperationTracker tracker,
-
-                                    @Symbol(SymbolConstants.MINIFICATION_ENABLED)
-                                    boolean minificationEnabled, ResourceChangeTracker resourceChangeTracker)
+    public StackAssetRequestHandler(LocalizationSetter localizationSetter,
+                                    ResponseCompressionAnalyzer compressionAnalyzer,
+                                    ResourceStreamer resourceStreamer,
+                                    OperationTracker tracker,
+                                    JavaScriptStackAssembler javaScriptStackAssembler)
     {
-        this.streamableResourceSource = streamableResourceSource;
-        this.javascriptStackSource = javascriptStackSource;
         this.localizationSetter = localizationSetter;
         this.compressionAnalyzer = compressionAnalyzer;
         this.resourceStreamer = resourceStreamer;
-        this.resourceMinimizer = resourceMinimizer;
         this.tracker = tracker;
-        this.minificationEnabled = minificationEnabled;
-        this.resourceChangeTracker = resourceChangeTracker;
-    }
-
-    @PostInjection
-    public void listenToInvalidations(ResourceChangeTracker resourceChangeTracker)
-    {
-        resourceChangeTracker.clearOnInvalidation(uncompressedCache);
-        resourceChangeTracker.clearOnInvalidation(compressedCache);
+        this.javaScriptStackAssembler = javaScriptStackAssembler;
     }
 
     public boolean handleAssetRequest(Request request, Response response, final String extraPath) throws IOException
@@ -115,114 +84,25 @@ public class StackAssetRequestHandler implements AssetRequestHandler
 
     private StreamableResource getResource(String extraPath, boolean compressed) throws IOException
     {
-        return compressed ? getCompressedResource(extraPath) : getUncompressedResource(extraPath);
-    }
-
-    private synchronized StreamableResource getCompressedResource(String extraPath) throws IOException
-    {
-        StreamableResource result = compressedCache.get(extraPath);
-
-        if (result == null)
-        {
-            StreamableResource uncompressed = getUncompressedResource(extraPath);
-            result = compressStream(uncompressed);
-            compressedCache.put(extraPath, result);
-        }
-
-        return result;
-    }
-
-    private synchronized StreamableResource getUncompressedResource(String extraPath) throws IOException
-    {
-        StreamableResource result = uncompressedCache.get(extraPath);
-
-        if (result == null)
-        {
-            result = assembleStackContent(extraPath);
-            uncompressedCache.put(extraPath, result);
-        }
-
-        return result;
-    }
-
-    private StreamableResource assembleStackContent(String extraPath) throws IOException
-    {
         Matcher matcher = pathPattern.matcher(extraPath);
 
         if (!matcher.matches())
-            throw new RuntimeException("Invalid path for a stack asset request.");
-
-        String localeName = matcher.group(1);
-        String stackName = matcher.group(2);
-
-        return assembleStackContent(localeName, stackName);
-    }
-
-    private StreamableResource assembleStackContent(String localeName, String stackName) throws IOException
-    {
-        localizationSetter.setNonPersistentLocaleFromLocaleName(localeName);
-
-        JavaScriptStack stack = javascriptStackSource.getStack(stackName);
-        List<Asset> libraries = stack.getJavaScriptLibraries();
-
-        StreamableResource stackContent = assembleStackContent(localeName, stackName, libraries);
-
-        return minificationEnabled ? resourceMinimizer.minimize(stackContent) : stackContent;
-    }
-
-    private StreamableResource assembleStackContent(String localeName, String stackName, List<Asset> libraries) throws IOException
-    {
-        ByteArrayOutputStream stream = new ByteArrayOutputStream();
-        OutputStreamWriter osw = new OutputStreamWriter(stream, "UTF-8");
-        PrintWriter writer = new PrintWriter(osw, true);
-        long lastModified = 0;
-
-        StringBuilder description = new StringBuilder(String.format("'%s' JavaScript stack, for locale %s, resources=", stackName, localeName));
-        String sep = "";
-
-        JSONArray paths = new JSONArray();
-
-        for (Asset library : libraries)
         {
-            String path = library.toClientURL();
-
-            paths.put(path);
-
-            writer.format("\n/* %s */;\n", path);
-
-            Resource resource = library.getResource();
-
-            description.append(sep).append(resource.toString());
-            sep = ", ";
-
-            StreamableResource streamable = streamableResourceSource.getStreamableResource(resource,
-                    StreamableResourceProcessing.FOR_AGGREGATION, resourceChangeTracker);
-
-            streamable.streamTo(stream);
-
-            lastModified = Math.max(lastModified, streamable.getLastModified());
+            throw new RuntimeException("Invalid path for a stack asset request.");
         }
 
-        writer.close();
+        // TODO: Extract the stack's aggregate checksum as well
 
-        return new StreamableResourceImpl(
-                description.toString(),
-                JAVASCRIPT_CONTENT_TYPE, CompressionStatus.COMPRESSABLE, lastModified,
-                new BytestreamCache(stream));
-    }
+        String localeName = matcher.group(2);
+        String stackName = matcher.group(3);
 
-    private StreamableResource compressStream(StreamableResource uncompressed) throws IOException
-    {
-        ByteArrayOutputStream compressed = new ByteArrayOutputStream();
-        OutputStream compressor = new BufferedOutputStream(new GZIPOutputStream(compressed));
-
-        uncompressed.streamTo(compressor);
+        // Yes, I have a big regret that the JavaScript stack stuff relies on this global, rather than
+        // having it passed around properly.
 
-        compressor.close();
+        localizationSetter.setNonPersistentLocaleFromLocaleName(localeName);
 
-        BytestreamCache cache = new BytestreamCache(compressed);
+        // TODO: Verify request checksum against actual
 
-        return new StreamableResourceImpl(uncompressed.getDescription(), JAVASCRIPT_CONTENT_TYPE, CompressionStatus.COMPRESSED,
-                uncompressed.getLastModified(), cache);
+        return javaScriptStackAssembler.assembleJavaScriptResourceForStack(stackName, compressed);
     }
 }

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/JavaScriptStackPathConstructorImpl.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/JavaScriptStackPathConstructorImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/JavaScriptStackPathConstructorImpl.java
index 02b0822..3ff8c06 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/JavaScriptStackPathConstructorImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/JavaScriptStackPathConstructorImpl.java
@@ -14,20 +14,23 @@
 
 package org.apache.tapestry5.internal.services.javascript;
 
-import java.util.List;
-
 import org.apache.tapestry5.Asset;
 import org.apache.tapestry5.SymbolConstants;
 import org.apache.tapestry5.func.F;
 import org.apache.tapestry5.func.Mapper;
-import org.apache.tapestry5.internal.services.RequestConstants;
+import org.apache.tapestry5.internal.services.assets.JavaScriptStackAssembler;
 import org.apache.tapestry5.ioc.annotations.Symbol;
 import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
+import org.apache.tapestry5.ioc.internal.util.InternalUtils;
 import org.apache.tapestry5.ioc.services.ThreadLocale;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
+import org.apache.tapestry5.services.assets.StreamableResource;
 import org.apache.tapestry5.services.javascript.JavaScriptStack;
 import org.apache.tapestry5.services.javascript.JavaScriptStackSource;
 
+import java.io.IOException;
+import java.util.List;
+
 public class JavaScriptStackPathConstructorImpl implements JavaScriptStackPathConstructor
 {
     private final ThreadLocale threadLocale;
@@ -36,6 +39,8 @@ public class JavaScriptStackPathConstructorImpl implements JavaScriptStackPathCo
 
     private final JavaScriptStackSource javascriptStackSource;
 
+    private final JavaScriptStackAssembler assembler;
+
     private final boolean combineScripts;
 
     private final Mapper<Asset, String> toPath = new Mapper<Asset, String>()
@@ -47,14 +52,15 @@ public class JavaScriptStackPathConstructorImpl implements JavaScriptStackPathCo
     };
 
     public JavaScriptStackPathConstructorImpl(ThreadLocale threadLocale, AssetPathConstructor assetPathConstructor,
-            JavaScriptStackSource javascriptStackSource,
-
-            @Symbol(SymbolConstants.COMBINE_SCRIPTS)
-            boolean combineScripts)
+                                              JavaScriptStackSource javascriptStackSource,
+                                              JavaScriptStackAssembler assembler,
+                                              @Symbol(SymbolConstants.COMBINE_SCRIPTS)
+                                              boolean combineScripts)
     {
         this.threadLocale = threadLocale;
         this.assetPathConstructor = assetPathConstructor;
         this.javascriptStackSource = javascriptStackSource;
+        this.assembler = assembler;
         this.combineScripts = combineScripts;
     }
 
@@ -65,7 +71,9 @@ public class JavaScriptStackPathConstructorImpl implements JavaScriptStackPathCo
         List<Asset> assets = stack.getJavaScriptLibraries();
 
         if (assets.size() > 1 && combineScripts)
+        {
             return combinedStackURL(stackName);
+        }
 
         return toPaths(assets);
     }
@@ -73,19 +81,27 @@ public class JavaScriptStackPathConstructorImpl implements JavaScriptStackPathCo
     private List<String> toPaths(List<Asset> assets)
     {
         assert assets != null;
+
         return F.flow(assets).map(toPath).toList();
     }
 
     private List<String> combinedStackURL(String stackName)
     {
-        String path = String.format("%s/%s.js", threadLocale.getLocale().toString(), stackName);
+        try
+        {
+            StreamableResource streamable = assembler.assembleJavaScriptResourceForStack(stackName, false);
 
-        // TODO: Come up with a virtual Resource that represents the actual combined contents. This may involve
-        // looping through the StreamableResourceSource and wrapping the result as a VirtualResource.
+            String path = stackName + ".js";
 
-        String stackURL = assetPathConstructor.constructAssetPath(RequestConstants.STACK_FOLDER, path, null);
+            String stackURL = assetPathConstructor.constructStackAssetPath(threadLocale.getLocale().toString(), path, streamable);
 
-        return CollectionFactory.newList(stackURL);
+            return CollectionFactory.newList(stackURL);
+        } catch (IOException ex)
+        {
+            throw new RuntimeException(String.format("Unable to construct path for '%s' JavaScript stack: %s",
+                    stackName,
+                    InternalUtils.toMessage(ex)), ex);
+        }
     }
 
 }

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetPathConstructor.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetPathConstructor.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetPathConstructor.java
index 1412a7b..17fb2b7 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetPathConstructor.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetPathConstructor.java
@@ -17,6 +17,8 @@ package org.apache.tapestry5.services.assets;
 import org.apache.tapestry5.ioc.Resource;
 import org.apache.tapestry5.ioc.annotations.IncompatibleChange;
 
+import java.io.IOException;
+
 /**
  * Encapsulates the logic or creating the path portion of an asset URL, including
  * the application version.
@@ -39,6 +41,20 @@ public interface AssetPathConstructor
      * @return path portion of asset URL, which is everything needed by the {@link org.apache.tapestry5.internal.services.AssetDispatcher}
      *         to find and stream the resource
      */
-    @IncompatibleChange(release = "5.4", details = "resource parameter added")
-    String constructAssetPath(String virtualFolder, String path, Resource resource);
+    @IncompatibleChange(release = "5.4", details = "resource parameter added, IOException may not be thrown")
+    String constructAssetPath(String virtualFolder, String path, Resource resource) throws IOException;
+
+    /**
+     * Constructs an asset path for a aggregated {@linkplain org.apache.tapestry5.services.javascript.JavaScriptStack stack}.
+     *
+     * @param localeName
+     *         name of the locale
+     * @param path
+     *         based on the name of the core stack
+     * @param resource
+     *         the aggregated stack (used when generating the checksum)
+     * @return path that identifies the checksum, locale, and path
+     * @since 5.4
+     */
+    String constructStackAssetPath(String localeName, String path, StreamableResource resource) throws IOException;
 }

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetsModule.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetsModule.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetsModule.java
index d78e925..0955427 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetsModule.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/assets/AssetsModule.java
@@ -48,6 +48,7 @@ public class AssetsModule
         binder.bind(ResourceChangeTracker.class, ResourceChangeTrackerImpl.class);
         binder.bind(ResourceMinimizer.class, MasterResourceMinimizer.class);
         binder.bind(AssetChecksumGenerator.class, AssetChecksumGeneratorImpl.class);
+        binder.bind(JavaScriptStackAssembler.class, JavaScriptStackAssemblerImpl.class);
     }
 
     @Contribute(AssetSource.class)

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/test/groovy/org/apache/tapestry5/integration/app1/LibraryTests.groovy
----------------------------------------------------------------------
diff --git a/tapestry-core/src/test/groovy/org/apache/tapestry5/integration/app1/LibraryTests.groovy b/tapestry-core/src/test/groovy/org/apache/tapestry5/integration/app1/LibraryTests.groovy
index 1aca779..67a72d8 100644
--- a/tapestry-core/src/test/groovy/org/apache/tapestry5/integration/app1/LibraryTests.groovy
+++ b/tapestry-core/src/test/groovy/org/apache/tapestry5/integration/app1/LibraryTests.groovy
@@ -1,8 +1,10 @@
 package org.apache.tapestry5.integration.app1
 
 import org.apache.tapestry5.integration.GroovyTapestryCoreTestCase
+import org.apache.tapestry5.test.TapestryTestConfiguration
 import org.testng.annotations.Test
 
+@TapestryTestConfiguration(webAppFolder = "src/test/app1")
 class LibraryTests extends GroovyTapestryCoreTestCase
 {
 
@@ -17,7 +19,9 @@ class LibraryTests extends GroovyTapestryCoreTestCase
 
         String assetURL = getAttribute("//img[@id='t5logo']/@src")
 
-        assert assetURL.contains("lib/alpha/pages")
+        def pattern = ~"/assets/lib/alpha/\\w+/pages/tapestry\\.png"
+
+        assert pattern.matcher(assetURL).matches()
 
         assertDownloadedAsset assetURL, "src/test/resources/org/apache/tapestry5/integration/locallib/alpha/pages/tapestry.png"
     }

http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/373f5cf5/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ContextAssetFactoryTest.java
----------------------------------------------------------------------
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ContextAssetFactoryTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ContextAssetFactoryTest.java
index 2f19fe0..cb0fa33 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ContextAssetFactoryTest.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ContextAssetFactoryTest.java
@@ -22,6 +22,8 @@ import org.apache.tapestry5.services.Context;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
 import org.testng.annotations.Test;
 
+import java.io.IOException;
+
 public class ContextAssetFactoryTest extends InternalBaseTestCase
 {
     private final IdentityAssetPathConverter converter = new IdentityAssetPathConverter();
@@ -42,7 +44,7 @@ public class ContextAssetFactoryTest extends InternalBaseTestCase
     }
 
     @Test
-    public void asset_client_URL()
+    public void asset_client_URL() throws IOException
     {
         Context context = mockContext();
         AssetPathConstructor apc = newMock(AssetPathConstructor.class);