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.parameters.Parameters; 035import org.apache.cocoon.ProcessingException; 036import org.apache.cocoon.components.ContextHelper; 037import org.apache.cocoon.environment.Response; 038import org.apache.commons.io.IOUtils; 039import org.apache.commons.lang3.ArrayUtils; 040import org.apache.commons.lang3.StringUtils; 041import org.apache.excalibur.source.Source; 042import org.apache.excalibur.source.SourceException; 043import org.apache.excalibur.source.TraversableSource; 044import org.apache.excalibur.source.impl.FileSource; 045 046import org.ametys.plugins.core.ui.resources.AbstractCompiledResourceHandler; 047 048/** 049 * Resource handler to compile any Vue.js resource on the fly if needed, or serve it 050 * The sources have to be located in a directory X/vuejs, while the resources will be sought at X/resources/vuejs 051 */ 052public class VueJsResourceHandler extends AbstractCompiledResourceHandler 053{ 054 @Override 055 protected boolean _isBasicallySupported(String source) 056 { 057 LocationParser lp = new LocationParser(source); 058 return lp.matches(); 059 } 060 061 @Override 062 public int getPriority() 063 { 064 return MAX_PRIORITY - 500; 065 } 066 067 public void generateResource(Source source, OutputStream out, Map objectModel, Parameters parameters, Map<String, Object> additionalParameters) throws IOException, ProcessingException 068 { 069 if (source.getURI().endsWith(".map")) 070 { 071 // Sourcemap url needs to be adapted 072 try (InputStream is = source.getInputStream()) 073 { 074 String mapContent = IOUtils.toString(is, StandardCharsets.UTF_8); 075 String mapContentFixed = StringUtils.replaceAll(mapContent, "\"webpack://[^\"]*(/vueserve/[^\\\"]*\")", "\".$1"); 076 IOUtils.write(mapContentFixed, out, StandardCharsets.UTF_8); 077 } 078 } 079 else 080 { 081 // Source where compile during #setup ; in method #getSourceToCompile 082 try (InputStream is = source.getInputStream()) 083 { 084 IOUtils.copy(is, out); 085 } 086 } 087 } 088 089 @Override 090 public long getLastModified(Source source, Parameters parameters) 091 { 092 return _getLastModified(source); 093 } 094 095 // Recursively get the most recent last modified date 096 private long _getLastModified(Source inputSource) 097 { 098 long result = inputSource.getLastModified(); 099 100 if (_isSourceDirectory(inputSource)) 101 { 102 TraversableSource folder = (TraversableSource) inputSource; 103 try 104 { 105 Collection<Source> children = folder.getChildren(); 106 for (Source child : children) 107 { 108 result = Math.max(result, _getLastModified(child)); 109 } 110 } 111 catch (SourceException e) 112 { 113 getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e); 114 } 115 116 } 117 118 return result; 119 } 120 121 122 @Override 123 protected List<String> getDependenciesList(Source inputSource) 124 { 125 List<String> dependencies = new ArrayList<>(); 126 127 if (_isSourceDirectory(inputSource)) 128 { 129 TraversableSource folder = (TraversableSource) inputSource; 130 try 131 { 132 Collection<Source> children = folder.getChildren(); 133 for (Source child : children) 134 { 135 dependencies.add(child.getURI()); 136 } 137 } 138 catch (SourceException e) 139 { 140 getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e); 141 } 142 } 143 144 return dependencies; 145 } 146 147 private boolean _isSourceDirectory(Source inputSource) 148 { 149 return inputSource instanceof TraversableSource && ((TraversableSource) inputSource).isCollection() && !inputSource.getURI().endsWith("/node_modules/") && !inputSource.getURI().endsWith("/dist/"); // FIXME should use LocationParser.__OUTPUT_DIR 150 } 151 152 @Override 153 protected Source getSourceToCompile(String rawLocation, Map<String, Object> additionalParameters) throws MalformedURLException, IOException 154 { 155 // css files are already minimized files 156 String location = rawLocation.replaceAll("\\.min\\.css$", ".css"); 157 158 // In this implementation, it is not: one source => one target file 159 // We cannot know if a given file will exists after compilation without compiling 160 // So we pre-compile all the time 161 162 163 LocationParser lp = new LocationParser(location); 164 if (!lp.matches()) 165 { 166 throw new IOException("Path does not match: " + location); 167 } 168 169 // Special case for source maps: we serve ".vue" files in the source directory 170 Source vueSourceFileSource = _handleSouresFiles(lp); 171 if (vueSourceFileSource != null) 172 { 173 return vueSourceFileSource; 174 } 175 176 // Now lets do the job 177 Source componentSource = _resolver.resolveURI(lp.getComponentLocation()); 178 Source binarySource = _resolver.resolveURI(lp.getBinaryLocation()); 179 180 synchronized (this) 181 { 182 // Are compiled files not up-to-date? 183 if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(componentSource)) 184 { 185 Source packageJsonSource = _resolver.resolveURI(lp.getPackageJSONLocation()); 186 if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(packageJsonSource)) 187 { 188 // we cannot get last modification time of node_modules (too long) 189 // so lets compare if package.json file is newer than last compilation 190 191 try 192 { 193 _installDependencies(lp); 194 } 195 catch (ProcessingException e) 196 { 197 throw new IOException("Error while retrieving dependencies: " + location, e); 198 } 199 } 200 201 try 202 { 203 _compile(lp, binarySource); 204 } 205 catch (ProcessingException e) 206 { 207 throw new IOException("Compilation error with module: " + location, e); 208 } 209 } 210 } 211 212 Source binaryResource = _resolver.resolveURI(lp.getBinaryLocation() + lp.getVuejsFile()); 213 return binaryResource; 214 } 215 216 private Source _handleSouresFiles(LocationParser lp) throws MalformedURLException, IOException 217 { 218 if (StringUtils.endsWith(lp.getVuejsFile(), ".vue")) 219 { 220 // Fix a chrome issue with sourcemaps file and cache... 221 Response response = ContextHelper.getResponse(_context); 222 response.setDateHeader("Expires", 0); 223 224 // This is related to sourcemap 225 Source originalResource = _resolver.resolveURI(lp.getSourceLocation() + lp.getVuejsFile()); 226 return originalResource; 227 } 228 else 229 { 230 return null; 231 } 232 233 } 234 235 private void _compile(LocationParser lp, Source binarySource) throws IOException, ProcessingException 236 { 237 if (getLogger().isInfoEnabled()) 238 { 239 getLogger().info("Compiling " + lp.getSourceLocation()); 240 } 241 242 File sourceDir = ((FileSource) _resolver.resolveURI(lp.getSourceLocation())).getFile(); 243 File outputDir = ((FileSource) binarySource).getFile(); 244 outputDir.delete(); 245 Process process = Runtime.getRuntime().exec(ArrayUtils.addAll(_getCmdExec(), new String[] {"vue", "build", "--target", "lib", "--dest", outputDir.getAbsolutePath()}), null, sourceDir); 246 247 List<String> out = new ArrayList<>(); 248 ReadStream inputStreamReader = new ReadStream(process.getInputStream(), out); 249 ReadStream errorStreamReader = new ReadStream(process.getErrorStream(), out); 250 Thread inputThread = new Thread(inputStreamReader); 251 Thread errorThread = new Thread(errorStreamReader); 252 inputThread.start(); 253 errorThread.start(); 254 255 try 256 { 257 int exitCode = process.waitFor(); 258 259 // We cannot get the process exit code since the CLI will always return 0... 260 // We cannot check if errorStreamReader.isEmpty() since it is not 261 // So let's search the ERROR keyword 262 String outAsString = StringUtils.join(out, '\n'); 263 264 if (exitCode != 0 || outAsString.contains("ERROR")) 265 { 266 throw new ProcessingException("Could not compile " + lp.getSourceLocation() + " due to:\n" + outAsString); 267 } 268 else if (getLogger().isInfoEnabled()) 269 { 270 getLogger().info("Compilation of " + lp.getSourceLocation() + " ended with:\n" + outAsString); 271 } 272 } 273 catch (InterruptedException e) 274 { 275 throw new ProcessingException(e); 276 } 277 } 278 279 private void _installDependencies(LocationParser lp) throws IOException, ProcessingException 280 { 281 if (getLogger().isInfoEnabled()) 282 { 283 getLogger().info("Getting dependencies of " + lp.getSourceLocation()); 284 } 285 286 File sourceDir = ((FileSource) _resolver.resolveURI(lp.getSourceLocation())).getFile(); 287 Process process = Runtime.getRuntime().exec(ArrayUtils.addAll(_getCmdExec(), new String[] {"npm", "install"}), null, sourceDir); 288 289 List<String> out = new ArrayList<>(); 290 ReadStream inputStreamReader = new ReadStream(process.getInputStream(), out); 291 ReadStream errorStreamReader = new ReadStream(process.getErrorStream(), out); 292 Thread inputThread = new Thread(inputStreamReader); 293 Thread errorThread = new Thread(errorStreamReader); 294 inputThread.start(); 295 errorThread.start(); 296 297 try 298 { 299 int exitCode = process.waitFor(); 300 301 // We cannot get the process exit code since the CLI will always return 0... 302 // We cannot check if errorStreamReader.isEmpty() since it is not 303 // So let's search the ERROR keyword 304 String outAsString = StringUtils.join(out, '\n'); 305 306 if (exitCode != 0 || outAsString.contains("ERROR")) 307 { 308 throw new ProcessingException("Could not get dependencies " + lp.getSourceLocation() + " due to:\n" + outAsString); 309 } 310 else if (getLogger().isInfoEnabled()) 311 { 312 getLogger().info("Installing dependencies of " + lp.getSourceLocation() + " ended with:\n" + outAsString); 313 } 314 } 315 catch (InterruptedException e) 316 { 317 throw new ProcessingException(e); 318 } 319 320 } 321 322 // We cannot execute directly "vue", because java do not take in the path env var 323 private String[] _getCmdExec() 324 { 325 if (System.getProperty("os.name").toLowerCase().startsWith("windows")) 326 { 327 return new String[] {"cmd", "/c"}; 328 } 329 else 330 { 331 return new String[] {}; 332 } 333 } 334 335 static final class LocationParser 336 { 337 private static final Pattern __LOCATION = Pattern.compile("^(.*)(/[^/]*)(/vuejs)(/[^/]*)(/.*)$"); 338 private static final String __OUTPUT_DIR = File.separator + "dist"; 339 private static final String __SOURCE_DIR = File.separator + "src"; 340 private static final String __PACKAGE_JSON_FILE = File.separator + "package.json"; 341 342 private String _location; 343 private String _mainLocation; 344 private String _parentFolder; 345 private String _vuejsDirectoryName; 346 private String _vuejsComponentName; 347 private String _vuejsFile; 348 349 LocationParser(String location) 350 { 351 _location = location; 352 353 Matcher matcher = __LOCATION.matcher(location); 354 if (matcher.matches()) 355 { 356 // cocoon://plugins/test/resources/vuejs/mycomponent/main.js 357 _mainLocation = matcher.group(1); // cocoon://plugins/test 358 _parentFolder = matcher.group(2); // /resources 359 _vuejsDirectoryName = matcher.group(3); // /vuesjs 360 _vuejsComponentName = matcher.group(4); // /mycomponent 361 _vuejsFile = matcher.group(5); // /main.js 362 } 363 } 364 365 boolean matches() 366 { 367 return _mainLocation != null; 368 } 369 370 String getLocation() 371 { 372 return _location; 373 } 374 375 String getParentFolder() 376 { 377 return _parentFolder; 378 } 379 380 String getComponentLocation() 381 { 382 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName; 383 } 384 385 String getPackageJSONLocation() 386 { 387 return getComponentLocation() + __PACKAGE_JSON_FILE; 388 } 389 390 String getVuejsFile() 391 { 392 return _vuejsFile; 393 } 394 395 String getBinaryLocation() 396 { 397 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __OUTPUT_DIR; 398 } 399 400 String getSourceLocation() 401 { 402 return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __SOURCE_DIR; 403 } 404 } 405 406 class ReadStream implements Runnable 407 { 408 private final InputStream _inputStream; 409 private final List<String> _sf; 410 private boolean _isEmpty; 411 412 ReadStream(InputStream inputStream, List<String> sf) 413 { 414 _inputStream = inputStream; 415 _sf = sf; 416 _isEmpty = true; 417 } 418 419 private BufferedReader getBufferedReader(InputStream is) 420 { 421 return new BufferedReader(new InputStreamReader(is)); 422 } 423 424 @Override 425 public void run() 426 { 427 BufferedReader br = getBufferedReader(_inputStream); 428 String line = ""; 429 try 430 { 431 while ((line = br.readLine()) != null) 432 { 433 _sf.add(line); 434 _isEmpty = false; 435 } 436 } 437 catch (IOException e) 438 { 439 e.printStackTrace(); 440 } 441 } 442 443 boolean isEmpty() 444 { 445 return _isEmpty; 446 } 447 } 448}