    001// Licensed under the Apache License, Version 2.0 (the "License");
    002// you may not use this file except in compliance with the License.
    003// You may obtain a copy of the License at
    004//
    005// http://www.apache.org/licenses/LICENSE-2.0
    006//
    007// Unless required by applicable law or agreed to in writing, software
    008// distributed under the License is distributed on an "AS IS" BASIS,
    009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    010// See the License for the specific language governing permissions and
    011// limitations under the License.
    012
    013package org.apache.tapestry5.internal.services.javascript;
    014
    015import org.apache.tapestry5.SymbolConstants;
    016import org.apache.tapestry5.dom.Element;
    017import org.apache.tapestry5.internal.InternalConstants;
    018import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker;
    019import org.apache.tapestry5.ioc.Messages;
    020import org.apache.tapestry5.ioc.Resource;
    021import org.apache.tapestry5.ioc.annotations.PostInjection;
    022import org.apache.tapestry5.ioc.annotations.Symbol;
    023import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
    024import org.apache.tapestry5.json.JSONArray;
    025import org.apache.tapestry5.json.JSONLiteral;
    026import org.apache.tapestry5.json.JSONObject;
    027import org.apache.tapestry5.services.AssetSource;
    028import org.apache.tapestry5.services.PathConstructor;
    029import org.apache.tapestry5.services.ResponseCompressionAnalyzer;
    030import org.apache.tapestry5.services.assets.StreamableResourceSource;
    031import org.apache.tapestry5.services.javascript.JavaScriptModuleConfiguration;
    032import org.apache.tapestry5.services.javascript.ModuleConfigurationCallback;
    033import org.apache.tapestry5.services.javascript.ModuleManager;
    034
    035import java.util.List;
    036import java.util.Map;
    037import java.util.Set;
    038
    039public class ModuleManagerImpl implements ModuleManager
    040{
    041
    042    private final ResponseCompressionAnalyzer compressionAnalyzer;
    043
    044    private final Messages globalMessages;
    045
    046    private final boolean compactJSON;
    047
    048    private final Map<String, Resource> shimModuleNameToResource = CollectionFactory.newMap();
    049
    050    private final Resource classpathRoot;
    051
    052    private final Set<String> extensions;
    053
    054    // Note: ConcurrentHashMap does not support null as a value, alas. We use classpathRoot as a null.
    055    private final Map<String, Resource> cache = CollectionFactory.newConcurrentMap();
    056
    057    private final JSONObject baseConfig;
    058
    059    private final String basePath, compressedBasePath;
    060
    061    public ModuleManagerImpl(ResponseCompressionAnalyzer compressionAnalyzer,
    062                             AssetSource assetSource,
    063                             Map<String, JavaScriptModuleConfiguration> configuration,
    064                             Messages globalMessages,
    065                             StreamableResourceSource streamableResourceSource,
    066                             @Symbol(SymbolConstants.COMPACT_JSON)
    067                             boolean compactJSON,
    068                             @Symbol(SymbolConstants.PRODUCTION_MODE)
    069                             boolean productionMode,
    070                             @Symbol(SymbolConstants.MODULE_PATH_PREFIX)
    071                             String modulePathPrefix,
    072                             PathConstructor pathConstructor)
    073    {
    074        this.compressionAnalyzer = compressionAnalyzer;
    075        this.globalMessages = globalMessages;
    076        this.compactJSON = compactJSON;
    077
    078        basePath = pathConstructor.constructClientPath(modulePathPrefix);
    079        compressedBasePath = pathConstructor.constructClientPath(modulePathPrefix + ".gz");
    080
    081        classpathRoot = assetSource.resourceForPath("");
    082        extensions = CollectionFactory.newSet("js");
    083
    084        extensions.addAll(streamableResourceSource.fileExtensionsForContentType(InternalConstants.JAVASCRIPT_CONTENT_TYPE));
    085
    086        baseConfig = buildBaseConfig(configuration, !productionMode);
    087    }
    088
    089    private String buildRequireJSConfig(List<ModuleConfigurationCallback> callbacks)
    090    {
    091        // This is the part that can vary from one request to another, based on the capabilities of the client.
    092        JSONObject config = baseConfig.copy().put("baseUrl", getBaseURL());
    093
    094        // TAP5-2196: allow changes to the configuration in a per-request basis.
    095        for (ModuleConfigurationCallback callback : callbacks)
    096        {
    097            config = callback.configure(config);
    098            assert config != null;
    099        }
    100
    101        // This part gets written out before any libraries are loaded (including RequireJS).
    102        return String.format("var require = %s;\n", config.toString(compactJSON));
    103    }
    104
    105    private JSONObject buildBaseConfig(Map<String, JavaScriptModuleConfiguration> configuration, boolean devMode)
    106    {
    107        JSONObject config = new JSONObject();
    108
    109        // In DevMode, wait up to five minutes for a script, as the developer may be using the debugger.
    110        if (devMode)
    111        {
    112            config.put("waitSeconds", 300);
    113        }
    114
    115        for (String name : configuration.keySet())
    116        {
    117            JavaScriptModuleConfiguration module = configuration.get(name);
    118
    119            shimModuleNameToResource.put(name, module.resource);
    120
    121            // Some modules (particularly overrides) just need an alternate location for their content
    122            // on the server.
    123            if (module.getNeedsConfiguration())
    124            {
    125                // Others are libraries being shimmed as AMD modules, and need some configuration
    126                // to ensure that everything hooks up properly on the client.
    127                addModuleToConfig(config, name, module);
    128            }
    129        }
    130        return config;
    131    }
    132
    133    private String getBaseURL()
    134    {
    135        return compressionAnalyzer.isGZipSupported() ? compressedBasePath : basePath;
    136    }
    137
    138    private void addModuleToConfig(JSONObject config, String name, JavaScriptModuleConfiguration module)
    139    {
    140        JSONObject shimConfig = config.in("shim");
    141
    142        boolean nestDependencies = false;
    143
    144        String exports = module.getExports();
    145
    146        if (exports != null)
    147        {
    148            shimConfig.in(name).put("exports", exports);
    149            nestDependencies = true;
    150        }
    151
    152        String initExpression = module.getInitExpression();
    153
    154        if (initExpression != null)
    155        {
    156            String function = String.format("function() { return %s; }", initExpression);
    157            shimConfig.in(name).put("init", new JSONLiteral(function));
    158            nestDependencies = true;
    159        }
    160
    161        List<String> dependencies = module.getDependencies();
    162
    163        if (dependencies != null)
    164        {
    165            JSONObject container = nestDependencies ? shimConfig.in(name) : shimConfig;
    166            String key = nestDependencies ? "deps" : name;
    167
    168            for (String dep : dependencies)
    169            {
    170                container.append(key, dep);
    171            }
    172        }
    173    }
    174
    175    @PostInjection
    176    public void setupInvalidation(ResourceChangeTracker tracker)
    177    {
    178        tracker.clearOnInvalidation(cache);
    179    }
    180
    181    public void writeConfiguration(Element body,
    182                                   List<ModuleConfigurationCallback> callbacks)
    183    {
    184        Element element = body.element("script", "type", "text/javascript");
    185
    186        // Build it each time because we don't know if the client supports GZip or not, and
    187        // (in development mode) URLs for some referenced assets could change (due to URLs
    188        // containing a checksum on the resource content).
    189        element.raw(buildRequireJSConfig(callbacks));
    190    }
    191
    192    public void writeInitialization(Element body, List<String> libraryURLs, List<?> inits)
    193    {
    194
    195        Element element = body.element("script", "type", "text/javascript");
    196
    197        element.raw(globalMessages.format("private-core-page-initialization-template",
    198                convert(libraryURLs),
    199                convert(inits)));
    200    }
    201
    202    private String convert(List<?> input)
    203    {
    204        return new JSONArray().putAll(input).toString(compactJSON);
    205    }
    206
    207    public Resource findResourceForModule(String moduleName)
    208    {
    209        Resource resource = cache.get(moduleName);
    210
    211        if (resource == null)
    212        {
    213            resource = resolveModuleNameToResource(moduleName);
    214            cache.put(moduleName, resource);
    215        }
    216
    217        // We're treating classpathRoot as a placeholder for null.
    218
    219        return resource == classpathRoot ? null : resource;
    220    }
    221
    222    private Resource resolveModuleNameToResource(String moduleName)
    223    {
    224        Resource resource = shimModuleNameToResource.get(moduleName);
    225
    226        if (resource != null)
    227        {
    228            return resource;
    229        }
    230
    231        // Tack on a fake extension; otherwise modules whose name includes a '.' get mangled
    232        // by Resource.withExtension().
    233        String baseName = String.format("/META-INF/modules/%s.EXT", moduleName);
    234
    235        Resource baseResource = classpathRoot.forFile(baseName);
    236
    237        for (String extension : extensions)
    238        {
    239            resource = baseResource.withExtension(extension);
    240
    241            if (resource.exists())
    242            {
    243                return resource;
    244            }
    245        }
    246
    247        // Return placeholder for null:
    248        return classpathRoot;
    249    }
    250}