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}