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