001/* 002 * Copyright 2019 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.ametys.plugins.core.ui.resources.vuejs; 018 019import java.io.BufferedReader; 020import java.io.File; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.InputStreamReader; 024import java.io.OutputStream; 025import java.net.MalformedURLException; 026import java.nio.charset.StandardCharsets; 027import java.util.ArrayList; 028import java.util.Collection; 029import java.util.List; 030import java.util.Map; 031import java.util.regex.Matcher; 032import java.util.regex.Pattern; 033 034import org.apache.avalon.framework.context.Context; 035import org.apache.avalon.framework.context.ContextException; 036import org.apache.avalon.framework.context.Contextualizable; 037import org.apache.avalon.framework.parameters.Parameters; 038import org.apache.avalon.framework.service.ServiceException; 039import org.apache.avalon.framework.service.ServiceManager; 040import org.apache.cocoon.ProcessingException; 041import org.apache.cocoon.components.ContextHelper; 042import org.apache.cocoon.environment.ObjectModelHelper; 043import org.apache.cocoon.environment.Request; 044import org.apache.cocoon.environment.Response; 045import org.apache.commons.io.IOUtils; 046import org.apache.commons.lang3.ArrayUtils; 047import org.apache.commons.lang3.RegExUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.apache.excalibur.source.Source; 050import org.apache.excalibur.source.SourceException; 051import org.apache.excalibur.source.TraversableSource; 052import org.apache.excalibur.source.impl.FileSource; 053 054import org.ametys.core.resources.ProxiedContextPathProvider; 055import org.ametys.plugins.core.ui.resources.AbstractCompiledResourceHandler; 056 057/** 058 * Resource handler to compile any VueJS resource on the fly if needed, or serve it 059 * The sources have to be located in a directory X/vuejs, while the resources will be sought at X/resources/vuejs 060 */ 061public class VueJsResourceHandler extends AbstractCompiledResourceHandler implements Contextualizable 062{ 063 private Context _context; 064 private LocationParser _lp; 065 private ProxiedContextPathProvider _proxiedContextPathProvider; 066 067 /** 068 * Constructor with an already resolved {@link Source}. 069 * @param source the source 070 */ 071 public VueJsResourceHandler(Source source) 072 { 073 super(source); 074 } 075 076 public void contextualize(Context context) throws ContextException 077 { 078 _context = context; 079 } 080 081 @Override 082 public void service(ServiceManager manager) throws ServiceException 083 { 084 super.service(manager); 085 _proxiedContextPathProvider = (ProxiedContextPathProvider) manager.lookup(ProxiedContextPathProvider.ROLE); 086 } 087 088 @Override 089 public Source setup(String rawLocation, Map objectModel, Parameters parameters, boolean readForDownload) throws IOException, ProcessingException 090 { 091 _requestedLocation = rawLocation; 092 _objectModel = objectModel; 093 _parameters = parameters; 094 _readForDownload = readForDownload; 095 096 // css files are already minimized files 097 String location = rawLocation.replaceAll("\\.min\\.css$", ".css"); 098 099 // In this implementation, it is not: one source => one target file 100 // We cannot know if a given file will exists after compilation without compiling 101 // So we pre-compile all the time 102 _lp = new LocationParser(location); 103 if (!_lp.matches()) 104 { 105 throw new IOException("Path does not match: " + location); 106 } 107 108 // Special case for source maps: we serve ".vue" files in the source directory 109 Source vueSourceFileSource = _handleSouresFiles(); 110 if (vueSourceFileSource != null) 111 { 112 _source = vueSourceFileSource; 113 return vueSourceFileSource; 114 } 115 116 // Now lets do the job 117 Source componentSource = _resolver.resolveURI(_lp.getComponentLocation()); 118 Source binarySource = _resolver.resolveURI(_lp.getBinaryLocation()); 119 120 synchronized (this) 121 { 122 // Are compiled files not up-to-date? 123 if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(componentSource)) 124 { 125 Source packageJsonSource = _resolver.resolveURI(_lp.getPackageJSONLocation()); 126 if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(packageJsonSource)) 127 { 128 // we cannot get last modification time of node_modules (too long) 129 // so lets compare if package.json file is newer than last compilation 130 131 try 132 { 133 _installDependencies(); 134 } 135 catch (ProcessingException e) 136 { 137 throw new IOException("Error while retrieving dependencies: " + location, e); 138 } 139 } 140 141 try 142 { 143 _compile(binarySource); 144 } 145 catch (ProcessingException e) 146 { 147 throw new IOException("Compilation error with module: " + location, e); 148 } 149 } 150 } 151 152 Source binaryResource = _resolver.resolveURI(_lp.getBinaryLocation() + _lp.getVuejsFile()); 153 _source = binaryResource; 154 return binaryResource; 155 } 156 157 private Source _handleSouresFiles() throws MalformedURLException, IOException 158 { 159 if (!StringUtils.equals(_lp.getComponent(), "/_components") && !StringUtils.endsWith(_lp.getVuejsFile(), ".vue") && !StringUtils.endsWith(_lp.getVuejsFile(), "/main.js")) 160 { 161 return null; 162 } 163 164 // Fix a chrome issue with sourcemaps file and cache... 165 Response response = ContextHelper.getResponse(_context); 166 response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); 167 response.setDateHeader("Expires", 0); 168 169 // the real path is artificially prefixed by the resource type to bypass browser issues 170 String file = _lp.getVuejsFile(); 171 file = file.substring(file.indexOf('/', 1)); // file is prefixed by /css or /js 172 173 String root = _lp.getSourceLocation(); 174 if (StringUtils.equals(_lp.getComponent(), "/_components")) 175 { 176 // for common sources, there's no 'src' folder 177 root = StringUtils.removeEnd(root, "/src"); 178 } 179 180 // This is related to sourcemap 181 Source originalResource = _resolver.resolveURI(root + file); 182 return originalResource; 183 } 184 185 private void _executeCommandeLine(String[] command, File directory, String successLog, String errorKeyword, String errorLog) throws IOException, ProcessingException 186 { 187 // We cannot execute directly "vue", because java do not take in the path env var 188 String[] cmdPrefix = System.getProperty("os.name").toLowerCase().startsWith("windows") ? new String[] {"cmd", "/c"} : new String[] {}; 189 190 Process process = Runtime.getRuntime().exec(ArrayUtils.addAll(cmdPrefix, command), null, directory); 191 192 List<String> out = new ArrayList<>(); 193 ReadStream inputStreamReader = new ReadStream(process.getInputStream(), out); 194 ReadStream errorStreamReader = new ReadStream(process.getErrorStream(), out); 195 Thread inputThread = new Thread(inputStreamReader); 196 Thread errorThread = new Thread(errorStreamReader); 197 inputThread.start(); 198 errorThread.start(); 199 200 try 201 { 202 int exitCode = process.waitFor(); 203 204 // We cannot get the process exit code since these executables always returns 0... 205 // We cannot check if errorStreamReader.isEmpty() since it is not 206 // So let's search the ERROR keyword 207 String outAsString = StringUtils.join(out, '\n'); 208 209 if (exitCode != 0 || outAsString.contains(errorKeyword)) 210 { 211 throw new ProcessingException(errorLog + "\n" + outAsString); 212 } 213 else if (getLogger().isInfoEnabled()) 214 { 215 getLogger().info(successLog + "\n" + outAsString); 216 } 217 } 218 catch (InterruptedException e) 219 { 220 throw new ProcessingException(e); 221 } 222 } 223 224 private void _compile(Source binarySource) throws IOException, ProcessingException 225 { 226 if (getLogger().isInfoEnabled()) 227 { 228 getLogger().info("Compiling " + _lp.getSourceLocation()); 229 } 230 231 File sourceDir = ((FileSource) _resolver.resolveURI(_lp.getSourceLocation())).getFile(); 232 File outputDir = ((FileSource) binarySource).getFile(); 233 outputDir.delete(); 234 _executeCommandeLine(new String[] {"vue", "build", "--target", "lib", "--dest", outputDir.getAbsolutePath()}, 235 sourceDir, 236 "Compilation of " + _lp.getSourceLocation() + " ended with", 237 "ERROR", 238 "Could not compile " + _lp.getSourceLocation() + " due to:"); 239 } 240 241 private void _installDependencies() throws IOException, ProcessingException 242 { 243 if (getLogger().isInfoEnabled()) 244 { 245 getLogger().info("Getting dependencies of " + _lp.getComponentLocation()); 246 } 247 248 File sourceDir = ((FileSource) _resolver.resolveURI(_lp.getComponentLocation())).getFile(); 249 _executeCommandeLine(new String[] {"npm", "install"}, 250 sourceDir, 251 "Installing dependencies of " + _lp.getComponentLocation() + " ended with:", 252 "ERR!", 253 "Could not get dependencies " + _lp.getComponentLocation() + " due to:"); 254 } 255 256 @Override 257 public void generate(OutputStream out) throws IOException, ProcessingException 258 { 259 if (_source.getURI().endsWith(".map")) 260 { 261 // Sourcemap url needs to be adapted 262 Request request = ObjectModelHelper.getRequest(_objectModel); 263 264 String currentURI = request.getSitemapURI(); 265 String currentPrefix = StringUtils.substringBefore(currentURI, "/vuejs"); 266 267 String sourceUri = currentURI.substring(0, currentURI.lastIndexOf('.')); // strip trailing .map 268 String ext = sourceUri.substring(sourceUri.lastIndexOf('.') + 1); 269 270 String componentPrefix = _proxiedContextPathProvider.getContextPath() + "/" + currentPrefix + "/vuejs" + _lp.getComponent() + "/" + ext; 271 String commonPrefix = _proxiedContextPathProvider.getContextPath() + "/" + currentPrefix + "/vuejs/_components/" + ext; 272 273 try (InputStream is = _source.getInputStream()) 274 { 275 // all following regexp aims to clean webpack-generated source maps 276 String mapContent = IOUtils.toString(is, StandardCharsets.UTF_8); 277 String mapContentFixed = RegExUtils.replaceAll(mapContent, "\"webpack://[^.\"]*(/vueserve/[^?\"]*\")", "\"" + componentPrefix + "$1"); // our sources from the component 278 279 if (ext.equals("js")) 280 { 281 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/\\.\\./_components(/[^?\"]*\\.vue\")", "\"" + commonPrefix + "$1"); // our common .vue 282 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/_components(/[^?\"]*\\.js\")", "\"" + commonPrefix + "$1"); // our common .js 283 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*(/main\\.js\")", "\"" + componentPrefix + "$1"); // our main.js 284 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://main/external \\\\\"[^\"]*\\\\\"\"", "\"unused\""); // external dependencies 285 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/node_modules/([^\"]*/)([^\"]*\\.js\")", "\"sources-unavailable://$1source-unavailable-$2"); // internal dependencies (node_modules) 286 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*\"", "\"unused\""); // other webpack-generated artefacts 287 } 288 else if (ext.equals("css")) 289 { 290 mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/_components(/[^?\"]*\\.vue\")", "\"" + commonPrefix + "$1"); // our common .vue 291 } 292 293 IOUtils.write(mapContentFixed, out, StandardCharsets.UTF_8); 294 } 295 } 296 else 297 { 298 super.generate(out); 299 } 300 } 301 302 @Override 303 public long getLastModified() 304 { 305 return _getLastModified(_source); 306 } 307 308 // Recursively get the most recent last modified date 309 private long _getLastModified(Source inputSource) 310 { 311 long result = inputSource.getLastModified(); 312 313 if (_isSourceDirectory(inputSource)) 314 { 315 TraversableSource folder = (TraversableSource) inputSource; 316 try 317 { 318 Collection<Source> children = folder.getChildren(); 319 for (Source child : children) 320 { 321 result = Math.max(result, _getLastModified(child)); 322 } 323 } 324 catch (SourceException e) 325 { 326 getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e); 327 } 328 329 } 330 331 return result; 332 } 333 334 @Override 335 protected List<String> getDependenciesList(Source inputSource) 336 { 337 List<String> dependencies = new ArrayList<>(); 338 339 if (_isSourceDirectory(inputSource)) 340 { 341 TraversableSource folder = (TraversableSource) inputSource; 342 try 343 { 344 Collection<Source> children = folder.getChildren(); 345 for (Source child : children) 346 { 347 dependencies.add(child.getURI()); 348 } 349 } 350 catch (SourceException e) 351 { 352 getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e); 353 } 354 } 355 356 return dependencies; 357 } 358 359 private boolean _isSourceDirectory(Source inputSource) 360 { 361 return inputSource instanceof TraversableSource && ((TraversableSource) inputSource).isCollection() && !inputSource.getURI().endsWith("/node_modules/") && !inputSource.getURI().endsWith("/dist/"); 362 } 363 364 static final class LocationParser 365 { 366 private static final Pattern __LOCATION = Pattern.compile("^(.*)(/[^/]*)(/vuejs)(/[^/]*)(/.*)$"); 367 private static final String __OUTPUT_DIR = "/dist"; 368 private static final String __SOURCE_DIR = "/src"; 369 private static final String __PACKAGE_JSON_FILE = File.separator + "package.json"; 370 371 private String _location; 372 private String _mainLocation; 373 private String _parentFolder; 374 private String _vuejsDirectoryName; 375 private String _vuejsComponentName; 376 private String _vuejsFile; 377 378 LocationParser(String location) 379 { 380 _location = location; 381 382 Matcher matcher = __LOCATION.matcher(location); 383 if (matcher.matches()) 384 { 385 // cocoon://plugins/test/resources/vuejs/mycomponent/main.js 386 _mainLocation = matcher.group(1); // cocoon://plugins/test 387 _parentFolder = matcher.group(2); // /resources 388 _vuejsDirectoryName = matcher.group(3); // /vuesjs 389 _vuejsComponentName = matcher.group(4); // /mycomponent 390 _vuejsFile = matcher.group(5); // /main.js 391 } 392 } 393 394 boolean matches() 395 { 396 return _mainLocation != null; 397 } 398 399 String getLocation() 400 { 401 return _location; 402 } 403 404 String getParentFolder() 405 { 406 return _parentFolder; 407 } 408 409 String getComponent() 410 { 411 return _vuejsComponentName; 412 } 413 414 String getComponentLocation() 415 { 416 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName; 417 } 418 419 String getPackageJSONLocation() 420 { 421 return getComponentLocation() + __PACKAGE_JSON_FILE; 422 } 423 424 String getVuejsFile() 425 { 426 return _vuejsFile; 427 } 428 429 String getBinaryLocation() 430 { 431 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __OUTPUT_DIR; 432 } 433 434 String getSourceLocation() 435 { 436 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __SOURCE_DIR; 437 } 438 } 439 440 class ReadStream implements Runnable 441 { 442 private final InputStream _inputStream; 443 private final List<String> _sf; 444 private boolean _isEmpty; 445 446 ReadStream(InputStream inputStream, List<String> sf) 447 { 448 _inputStream = inputStream; 449 _sf = sf; 450 _isEmpty = true; 451 } 452 453 private BufferedReader getBufferedReader(InputStream is) 454 { 455 return new BufferedReader(new InputStreamReader(is)); 456 } 457 458 @Override 459 public void run() 460 { 461 BufferedReader br = getBufferedReader(_inputStream); 462 String line = ""; 463 try 464 { 465 while ((line = br.readLine()) != null) 466 { 467 _sf.add(line); 468 _isEmpty = false; 469 } 470 } 471 catch (IOException e) 472 { 473 e.printStackTrace(); 474 } 475 } 476 477 boolean isEmpty() 478 { 479 return _isEmpty; 480 } 481 } 482}