diff --git a/src/dorkbox/vaadin/VaadinApplication.kt b/src/dorkbox/vaadin/VaadinApplication.kt index f84cc73..162b30a 100644 --- a/src/dorkbox/vaadin/VaadinApplication.kt +++ b/src/dorkbox/vaadin/VaadinApplication.kt @@ -4,6 +4,7 @@ import com.vaadin.flow.server.Constants import com.vaadin.flow.server.InitParameters import com.vaadin.flow.server.VaadinServlet import com.vaadin.flow.server.frontend.FrontendUtils +import dorkbox.vaadin.devMode.DevModeInitializer import dorkbox.vaadin.undertow.* import dorkbox.vaadin.util.CallingClass import dorkbox.vaadin.util.ahoCorasick.DoubleArrayTrie @@ -502,7 +503,7 @@ class VaadinApplication() { // instead of the default, we load **OUR** dev-mode initializer. // The vaadin one is super buggy for custom environments servletBuilder.addServletContainerInitializer( - ServletContainerInitializerInfo(dorkbox.vaadin.DevModeInitializer::class.java, classSet)) + ServletContainerInitializerInfo(DevModeInitializer::class.java, classSet)) } } else { // do not load the dev-mode initializer for production mode diff --git a/src/dorkbox/vaadin/DevModeClassFinder.kt b/src/dorkbox/vaadin/devMode/DevModeClassFinder.kt similarity index 83% rename from src/dorkbox/vaadin/DevModeClassFinder.kt rename to src/dorkbox/vaadin/devMode/DevModeClassFinder.kt index 92f839d..88f9335 100644 --- a/src/dorkbox/vaadin/DevModeClassFinder.kt +++ b/src/dorkbox/vaadin/devMode/DevModeClassFinder.kt @@ -1,9 +1,18 @@ -package dorkbox.vaadin +package dorkbox.vaadin.devMode import com.vaadin.flow.server.frontend.scanner.ClassFinder import java.util.* import javax.servlet.annotation.HandlesTypes +/** + * THIS IS COPIED DIRECTLY FROM VAADIN 14.6.8 (flow 2.4.6) + * + * CHANGES FROM DEFAULT ARE MANAGED AS DIFFERENT REVISIONS. + * + * The initial commit is exactly as-is from vaadin. + * + * This file is NOT extensible/configurable AT-ALL, so this is required... + */ internal class DevModeClassFinder(classes: Set?>?) : ClassFinder.DefaultClassFinder(classes) { companion object { private val APPLICABLE_CLASS_NAMES = Collections.unmodifiableSet(calculateApplicableClassNames()) diff --git a/src/dorkbox/vaadin/devMode/DevModeHandler.java b/src/dorkbox/vaadin/devMode/DevModeHandler.java new file mode 100644 index 0000000..2a1dc2c --- /dev/null +++ b/src/dorkbox/vaadin/devMode/DevModeHandler.java @@ -0,0 +1,862 @@ +/* + * Copyright 2000-2020 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package dorkbox.vaadin.devMode; + +import static com.vaadin.flow.server.InitParameters.SERVLET_PARAMETER_DEVMODE_WEBPACK_ERROR_PATTERN; +import static com.vaadin.flow.server.InitParameters.SERVLET_PARAMETER_DEVMODE_WEBPACK_OPTIONS; +import static com.vaadin.flow.server.InitParameters.SERVLET_PARAMETER_DEVMODE_WEBPACK_SUCCESS_PATTERN; +import static com.vaadin.flow.server.InitParameters.SERVLET_PARAMETER_DEVMODE_WEBPACK_TIMEOUT; +import static com.vaadin.flow.server.frontend.FrontendUtils.GREEN; +import static com.vaadin.flow.server.frontend.FrontendUtils.RED; +import static com.vaadin.flow.server.frontend.FrontendUtils.YELLOW; +import static com.vaadin.flow.server.frontend.FrontendUtils.commandToString; +import static com.vaadin.flow.server.frontend.FrontendUtils.console; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.internal.BrowserLiveReload; +import com.vaadin.flow.internal.Pair; +import com.vaadin.flow.server.ExecutionFailedException; +import com.vaadin.flow.server.InitParameters; +import com.vaadin.flow.server.RequestHandler; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinResponse; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.server.communication.StreamRequestHandler; +import com.vaadin.flow.server.frontend.FrontendTools; +import com.vaadin.flow.server.frontend.FrontendUtils; + +/** + * THIS IS COPIED DIRECTLY FROM VAADIN 14.6.8 (flow 2.4.6) + * + * CHANGES FROM DEFAULT ARE MANAGED AS DIFFERENT REVISIONS. + * + * The initial commit is exactly as-is from vaadin. + * + * This file is NOT extensible/configurable AT-ALL, so this is required... + * + * + * + * Handles getting resources from webpack-dev-server. + *

+ * This class is meant to be used during developing time. For a production mode + * site webpack generates the static bundles that will be served + * directly from the servlet (using a default servlet if such exists) or through + * a stand alone static file server. + * + * By default it keeps updated npm dependencies and node imports before running + * webpack server + * + * @since 2.0 + */ +public final class DevModeHandler implements RequestHandler { + + private static final String START_FAILURE = "Couldn't start dev server because"; + + private static final AtomicReference atomicHandler = new AtomicReference<>(); + + // It's not possible to know whether webpack is ready unless reading output + // messages. When webpack finishes, it writes either a `Compiled` or a + // `Failed` in the last line + private static final String DEFAULT_OUTPUT_PATTERN = ": Compiled."; + private static final String DEFAULT_ERROR_PATTERN = ": Failed to compile."; + private static final String FAILED_MSG = "\n------------------ Frontend compilation failed. -----------------"; + private static final String SUCCEED_MSG = "\n----------------- Frontend compiled successfully. -----------------"; + private static final String START = "\n------------------ Starting Frontend compilation. ------------------\n"; + private static final String END = "\n------------------------- Webpack stopped -------------------------\n"; + private static final String LOG_START = "Running webpack to compile frontend resources. This may take a moment, please stand by..."; + private static final String LOG_END = "Started webpack-dev-server. Time: {}ms"; + + // If after this time in millisecs, the pattern was not found, we unlock the + // process and continue. It might happen if webpack changes their output + // without advise. + private static final String DEFAULT_TIMEOUT_FOR_PATTERN = "60000"; + + private static final int DEFAULT_BUFFER_SIZE = 32 * 1024; + private static final int DEFAULT_TIMEOUT = 120 * 1000; + private static final String WEBPACK_HOST = "http://localhost"; + + private boolean notified = false; + + private volatile String failedOutput; + + private AtomicBoolean isDevServerFailedToStart = new AtomicBoolean(); + + private transient BrowserLiveReload liveReload; + + /** + * The local installation path of the webpack-dev-server node script. + */ + public static final String WEBPACK_SERVER = "node_modules/webpack-dev-server/bin/webpack-dev-server.js"; + + private volatile int port; + private final AtomicReference webpackProcess = new AtomicReference<>(); + private final boolean reuseDevServer; + private final AtomicReference watchDog = new AtomicReference<>(); + + private final CompletableFuture devServerStartFuture; + + private final File npmFolder; + + private DevModeHandler(DeploymentConfiguration config, int runningPort, + File npmFolder, CompletableFuture waitFor) { + this.npmFolder = Objects.requireNonNull(npmFolder); + + port = runningPort; + reuseDevServer = config.reuseDevServer(); + + // Check whether executor is provided by the caller (framework) + Object service = config.getInitParameters().get(Executor.class); + + BiConsumer action = (value, exception) -> { + // this will throw an exception if an exception has been thrown by + // the waitFor task + waitFor.getNow(null); + runOnFutureComplete(config); + }; + + if (service instanceof Executor) { + // if there is an executor use it to run the task + devServerStartFuture = waitFor.whenCompleteAsync(action, + (Executor) service); + } else { + devServerStartFuture = waitFor.whenCompleteAsync(action); + } + } + + /** + * Start the dev mode handler if none has been started yet. + * + * @param configuration + * deployment configuration + * @param npmFolder + * folder with npm configuration files + * @param waitFor + * a completable future whose execution result needs to be + * available to start the webpack dev server + * + * @return the instance in case everything is alright, null otherwise + */ + public static DevModeHandler start(DeploymentConfiguration configuration, + File npmFolder, CompletableFuture waitFor) { + return start(0, configuration, npmFolder, waitFor); + } + + /** + * Start the dev mode handler if none has been started yet. + * + * @param runningPort + * port on which Webpack is listening. + * @param configuration + * deployment configuration + * @param npmFolder + * folder with npm configuration files + * @param waitFor + * a completable future whose execution result needs to be + * available to start the webpack dev server + * + * @return the instance in case everything is alright, null otherwise + */ + public static DevModeHandler start(int runningPort, + DeploymentConfiguration configuration, File npmFolder, + CompletableFuture waitFor) { + if (configuration.isProductionMode() + || configuration.isCompatibilityMode() + || !configuration.enableDevServer()) { + return null; + } + DevModeHandler handler = atomicHandler.get(); + if (handler == null) { + handler = createInstance(runningPort, configuration, npmFolder, + waitFor); + atomicHandler.compareAndSet(null, handler); + } + + return getDevModeHandler(); + } + + /** + * Get the instantiated DevModeHandler. + * + * @return devModeHandler or {@code null} if not started + */ + public static DevModeHandler getDevModeHandler() { + return atomicHandler.get(); + } + + @Override + public boolean handleRequest(VaadinSession session, VaadinRequest request, + VaadinResponse response) throws IOException { + if (devServerStartFuture.isDone()) { + try { + devServerStartFuture.getNow(null); + } catch (CompletionException exception) { + isDevServerFailedToStart.set(true); + throw getCause(exception); + } + return false; + } else { + InputStream inputStream = DevModeHandler.class + .getResourceAsStream("dev-mode-not-ready.html"); + IOUtils.copy(inputStream, response.getOutputStream()); + response.setContentType("text/html;charset=utf-8"); + return true; + } + } + + private RuntimeException getCause(Throwable exception) { + if (exception instanceof CompletionException) { + return getCause(exception.getCause()); + } else if (exception instanceof RuntimeException) { + return (RuntimeException) exception; + } else { + return new IllegalStateException(exception); + } + } + + /** + * Set the live reload service instance. + * + * @param liveReload + * the live reload instance + */ + public void setLiveReload(BrowserLiveReload liveReload) { + this.liveReload = liveReload; + } + + /** + * Get the live reload service instance. + * + * @return the live reload instance + */ + public BrowserLiveReload getLiveReload() { + return liveReload; + } + + private static DevModeHandler createInstance(int runningPort, + DeploymentConfiguration configuration, File npmFolder, + CompletableFuture waitFor) { + + return new DevModeHandler(configuration, runningPort, npmFolder, + waitFor); + } + + /** + * Returns true if it's a request that should be handled by webpack. + * + * @param request + * the servlet request + * @return true if the request should be forwarded to webpack + */ + public boolean isDevModeRequest(HttpServletRequest request) { + final String pathInfo = request.getPathInfo(); + return pathInfo != null && pathInfo.matches(".+\\.js") && !pathInfo + .startsWith("/" + StreamRequestHandler.DYN_RES_PREFIX); + } + + /** + * Serve a file by proxying to webpack. + *

+ * Note: it considers the {@link HttpServletRequest#getPathInfo} that will + * be the path passed to the 'webpack-dev-server' which is running in the + * context root folder of the application. + *

+ * Method returns {@code false} immediately if dev server failed on its + * startup. + * + * @param request + * the servlet request + * @param response + * the servlet response + * @return false if webpack returned a not found, true otherwise + * @throws IOException + * in the case something went wrong like connection refused + */ + public boolean serveDevModeRequest(HttpServletRequest request, + HttpServletResponse response) throws IOException { + // Do not serve requests if dev server starting or failed to start. + if (isDevServerFailedToStart.get() || !devServerStartFuture.isDone()) { + return false; + } + // Since we have 'publicPath=/VAADIN/' in webpack config, + // a valid request for webpack-dev-server should start with '/VAADIN/' + String requestFilename = request.getPathInfo(); + + if (HandlerHelper.isPathUnsafe(requestFilename)) { + getLogger().info("Blocked attempt to access file: {}", + requestFilename); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return true; + } + + HttpURLConnection connection = prepareConnection(requestFilename, + request.getMethod()); + + // Copies all the headers from the original request + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String header = headerNames.nextElement(); + connection.setRequestProperty(header, + // Exclude keep-alive + "Connect".equals(header) ? "close" + : request.getHeader(header)); + } + + // Send the request + getLogger().debug("Requesting resource to webpack {}", + connection.getURL()); + int responseCode = connection.getResponseCode(); + if (responseCode == HTTP_NOT_FOUND) { + getLogger().debug("Resource not served by webpack {}", + requestFilename); + // webpack cannot access the resource, return false so as flow can + // handle it + return false; + } + getLogger().debug("Served resource by webpack: {} {}", responseCode, + requestFilename); + + // Copies response headers + connection.getHeaderFields().forEach((header, values) -> { + if (header != null) { + response.addHeader(header, values.get(0)); + } + }); + + if (responseCode == HTTP_OK) { + // Copies response payload + writeStream(response.getOutputStream(), + connection.getInputStream()); + } else if (responseCode < 400) { + response.setStatus(responseCode); + } else { + // Copies response code + response.sendError(responseCode); + } + + // Close request to avoid issues in CI and Chrome + response.getOutputStream().close(); + + return true; + } + + private boolean checkWebpackConnection() { + try { + prepareConnection("/", "GET").getResponseCode(); + return true; + } catch (IOException e) { + getLogger().debug("Error checking webpack dev server connection", + e); + } + return false; + } + + /** + * Prepare a HTTP connection against webpack-dev-server. + * + * @param path + * the file to request + * @param method + * the http method to use + * @return the connection + * @throws IOException + * on connection error + */ + public HttpURLConnection prepareConnection(String path, String method) + throws IOException { + URL uri = new URL(WEBPACK_HOST + ":" + getPort() + path); + HttpURLConnection connection = (HttpURLConnection) uri.openConnection(); + connection.setRequestMethod(method); + connection.setReadTimeout(DEFAULT_TIMEOUT); + connection.setConnectTimeout(DEFAULT_TIMEOUT); + return connection; + } + + private synchronized void doNotify() { + if (!notified) { + notified = true; + notifyAll(); // NOSONAR + } + } + + // mirrors a stream to logger, and check whether a success or error pattern + // is found in the output. + private void logStream(InputStream input, Pattern success, + Pattern failure) { + Thread thread = new Thread(() -> { + BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8)); + try { + readLinesLoop(success, failure, reader); + } catch (IOException e) { + if ("Stream closed".equals(e.getMessage())) { + console(GREEN, END); + getLogger().debug("Exception when reading webpack output.", + e); + } else { + getLogger().error("Exception when reading webpack output.", + e); + } + } + + // Process closed stream, means that it exited, notify + // DevModeHandler to continue + doNotify(); + }); + thread.setDaemon(true); + thread.setName("webpack"); + thread.start(); + } + + private void readLinesLoop(Pattern success, Pattern failure, + BufferedReader reader) throws IOException { + StringBuilder output = getOutputBuilder(); + + Consumer info = s -> getLogger() + .debug(String.format(GREEN, "{}"), s); + Consumer error = s -> getLogger() + .error(String.format(RED, "{}"), s); + Consumer warn = s -> getLogger() + .debug(String.format(YELLOW, "{}"), s); + Consumer log = info; + for (String line; ((line = reader.readLine()) != null);) { + String cleanLine = line + // remove color escape codes for console + .replaceAll("\u001b\\[[;\\d]*m", "") + // remove babel query string which is confusing + .replaceAll("\\?babel-target=[\\w\\d]+", ""); + + // write each line read to logger, but selecting its correct level + log = line.contains("WARNING") ? warn + : line.contains("ERROR") ? error + : isInfo(line, cleanLine) ? info : log; + log.accept(cleanLine); + + // Only store webpack errors to be shown in the browser. + if (log.equals(error)) { + // save output so as it can be used to alert user in browser. + output.append(cleanLine).append(System.lineSeparator()); + } + + boolean succeed = success.matcher(line).find(); + boolean failed = failure.matcher(line).find(); + // We found the success or failure pattern in stream + if (succeed || failed) { + log.accept(succeed ? SUCCEED_MSG : FAILED_MSG); + // save output in case of failure + failedOutput = failed ? output.toString() : null; + // reset output and logger for the next compilation + output = getOutputBuilder(); + log = info; + // Notify DevModeHandler to continue + doNotify(); + } + } + } + + private boolean isInfo(String line, String cleanLine) { + return line.trim().isEmpty() || cleanLine.trim().startsWith("i"); + } + + private StringBuilder getOutputBuilder() { + StringBuilder output = new StringBuilder(); + output.append(String.format("Webpack build failed with errors:%n")); + return output; + } + + private void writeStream(ServletOutputStream outputStream, + InputStream inputStream) throws IOException { + final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int bytes; + while ((bytes = inputStream.read(buffer)) >= 0) { + outputStream.write(buffer, 0, bytes); + } + } + + private static Logger getLogger() { + // Using an short prefix so as webpack output is more readable + return LoggerFactory.getLogger("dev-webpack"); + } + + /** + * Return webpack console output when a compilation error happened. + * + * @return console output if error or null otherwise. + */ + public String getFailedOutput() { + return failedOutput; + } + + /** + * Remove the running port from the vaadinContext and temporary file. + */ + public void removeRunningDevServerPort() { + FileUtils.deleteQuietly(LazyDevServerPortFileInit.DEV_SERVER_PORT_FILE); + } + + private void runOnFutureComplete(DeploymentConfiguration config) { + try { + doStartDevModeServer(config); + } catch (ExecutionFailedException exception) { + getLogger().error(null, exception); + throw new CompletionException(exception); + } + } + + private void saveRunningDevServerPort() { + File portFile = LazyDevServerPortFileInit.DEV_SERVER_PORT_FILE; + try { + FileUtils.writeStringToFile(portFile, String.valueOf(port), + StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void doStartDevModeServer(DeploymentConfiguration config) + throws ExecutionFailedException { + // If port is defined, means that webpack is already running + if (port > 0) { + if (!checkWebpackConnection()) { + throw new IllegalStateException(String.format( + "%s webpack-dev-server port '%d' is defined but it's not working properly", + START_FAILURE, port)); + } + reuseExistingPort(port); + return; + } + port = getRunningDevServerPort(); + if (port > 0) { + if (checkWebpackConnection()) { + reuseExistingPort(port); + return; + } else { + getLogger().warn( + "webpack-dev-server port '%d' is defined but it's not working properly. Using a new free port...", + port); + port = 0; + } + } + // here the port == 0 + Pair webPackFiles = validateFiles(npmFolder); + + getLogger().info("Starting webpack-dev-server"); + + watchDog.set(new DevServerWatchDog()); + + // Look for a free port + port = getFreePort(); + saveRunningDevServerPort(); + boolean success = false; + try { + success = doStartWebpack(config, webPackFiles); + } finally { + if (!success) { + removeRunningDevServerPort(); + } + } + } + + private boolean doStartWebpack(DeploymentConfiguration config, + Pair webPackFiles) { + ProcessBuilder processBuilder = new ProcessBuilder() + .directory(npmFolder); + + FrontendTools tools = new FrontendTools(npmFolder.getAbsolutePath(), + () -> FrontendUtils.getVaadinHomeDirectory().getAbsolutePath()); + tools.validateNodeAndNpmVersion(); + + boolean useHomeNodeExec = config.getBooleanProperty( + InitParameters.REQUIRE_HOME_NODE_EXECUTABLE, false); + + String nodeExec = null; + if (useHomeNodeExec) { + nodeExec = tools.forceAlternativeNodeExecutable(); + } else { + nodeExec = tools.getNodeExecutable(); + } + + List command = makeCommands(config, webPackFiles.getFirst(), + webPackFiles.getSecond(), nodeExec); + + console(GREEN, START); + if (getLogger().isDebugEnabled()) { + getLogger().debug( + commandToString(npmFolder.getAbsolutePath(), command)); + } + + long start = System.nanoTime(); + processBuilder.command(command); + try { + webpackProcess.set( + processBuilder.redirectError(ProcessBuilder.Redirect.PIPE) + .redirectErrorStream(true).start()); + + // We only can save the webpackProcess reference the first time that + // the DevModeHandler is created. There is no way to store + // it in the servlet container, and we do not want to save it in the + // global JVM. + // We instruct the JVM to stop the webpack-dev-server daemon when + // the JVM stops, to avoid leaving daemons running in the system. + // NOTE: that in the corner case that the JVM crashes or it is + // killed + // the daemon will be kept running. But anyways it will also happens + // if the system was configured to be stop the daemon when the + // servlet context is destroyed. + Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + + Pattern succeed = Pattern.compile(config.getStringProperty( + SERVLET_PARAMETER_DEVMODE_WEBPACK_SUCCESS_PATTERN, + DEFAULT_OUTPUT_PATTERN)); + + Pattern failure = Pattern.compile(config.getStringProperty( + SERVLET_PARAMETER_DEVMODE_WEBPACK_ERROR_PATTERN, + DEFAULT_ERROR_PATTERN)); + + logStream(webpackProcess.get().getInputStream(), succeed, failure); + + getLogger().info(LOG_START); + synchronized (this) { + this.wait(Integer.parseInt(config.getStringProperty( // NOSONAR + SERVLET_PARAMETER_DEVMODE_WEBPACK_TIMEOUT, + DEFAULT_TIMEOUT_FOR_PATTERN))); + } + + if (!webpackProcess.get().isAlive()) { + throw new IllegalStateException("Webpack exited prematurely"); + } + + long ms = (System.nanoTime() - start) / 1000000; + getLogger().info(LOG_END, ms); + saveRunningDevServerPort(); + return true; + } catch (IOException e) { + getLogger().error("Failed to start the webpack process", e); + } catch (InterruptedException e) { + getLogger().debug("Webpack process start has been interrupted", e); + } + return false; + } + + private void reuseExistingPort(int port) { + getLogger().info("Reusing webpack-dev-server running at {}:{}", + WEBPACK_HOST, port); + + // Save running port for next usage + saveRunningDevServerPort(); + watchDog.set(null); + } + + private List makeCommands(DeploymentConfiguration config, + File webpack, File webpackConfig, String nodeExec) { + List command = new ArrayList<>(); + command.add(nodeExec); + command.add(webpack.getAbsolutePath()); + command.add("--config"); + command.add(webpackConfig.getAbsolutePath()); + command.add("--port"); + command.add(String.valueOf(port)); + command.add("--watchDogPort=" + watchDog.get().getWatchDogPort()); + command.addAll(Arrays.asList(config + .getStringProperty(SERVLET_PARAMETER_DEVMODE_WEBPACK_OPTIONS, + "-d --inline=false") + .split(" +"))); + return command; + } + + private Pair validateFiles(File npmFolder) + throws ExecutionFailedException { + assert port == 0; + // Skip checks if we have a webpack-dev-server already running + File webpack = new File(npmFolder, WEBPACK_SERVER); + File webpackConfig = new File(npmFolder, FrontendUtils.WEBPACK_CONFIG); + if (!npmFolder.exists()) { + getLogger().warn("No project folder '{}' exists", npmFolder); + throw new ExecutionFailedException(START_FAILURE + + " the target execution folder doesn't exist."); + } + if (!webpack.exists()) { + getLogger().warn("'{}' doesn't exist. Did you run `npm install`?", + webpack); + throw new ExecutionFailedException(String.format( + "%s '%s' doesn't exist. `npm install` has not run or failed.", + START_FAILURE, webpack)); + } else if (!webpack.canExecute()) { + getLogger().warn( + " '{}' is not an executable. Did you run `npm install`?", + webpack); + throw new ExecutionFailedException(String.format( + "%s '%s' is not an executable." + + " `npm install` has not run or failed.", + START_FAILURE, webpack)); + } + if (!webpackConfig.canRead()) { + getLogger().warn( + "Webpack configuration '{}' is not found or is not readable.", + webpackConfig); + throw new ExecutionFailedException( + String.format("%s '%s' doesn't exist or is not readable.", + START_FAILURE, webpackConfig)); + } + return new Pair<>(webpack, webpackConfig); + } + + private static int getRunningDevServerPort() { + int port = 0; + File portFile = LazyDevServerPortFileInit.DEV_SERVER_PORT_FILE; + if (portFile.canRead()) { + try { + String portString = FileUtils + .readFileToString(portFile, StandardCharsets.UTF_8) + .trim(); + if (!portString.isEmpty()) { + port = Integer.parseInt(portString); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return port; + } + + /** + * Returns an available tcp port in the system. + * + * @return a port number which is not busy + */ + static int getFreePort() { + try (ServerSocket s = new ServerSocket(0)) { + s.setReuseAddress(true); + return s.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException( + "Unable to find a free port for running webpack", e); + } + } + + /** + * Get the listening port of the 'webpack-dev-server'. + * + * @return the listening port of webpack + */ + public int getPort() { + return port; + } + + /** + * Whether the 'webpack-dev-server' should be reused on servlet reload. + * Default true. + * + * @return true in case of reusing the server. + */ + public boolean reuseDevServer() { + return reuseDevServer; + } + + /** + * Stop the webpack-dev-server. + */ + public void stop() { + if (atomicHandler.get() == null) { + return; + } + + try { + // The most reliable way to stop the webpack-dev-server is + // by informing webpack to exit. We have implemented in webpack a + // a listener that handles the stop command via HTTP and exits. + prepareConnection("/stop", "GET").getResponseCode(); + } catch (IOException e) { + getLogger().debug( + "webpack-dev-server does not support the `/stop` command.", + e); + } + + DevServerWatchDog watchDogInstance = watchDog.get(); + if (watchDogInstance != null) { + watchDogInstance.stop(); + } + + Process process = webpackProcess.get(); + if (process != null && process.isAlive()) { + process.destroy(); + } + + atomicHandler.set(null); + removeRunningDevServerPort(); + } + + /** + * Waits for the dev server to start. + *

+ * Suspends the caller's thread until the dev mode server is started (or + * failed to start). + * + * @see Thread#join() + */ + void join() { + devServerStartFuture.join(); + } + + private static final class LazyDevServerPortFileInit { + + private static final File DEV_SERVER_PORT_FILE = createDevServerPortFile(); + + private static File createDevServerPortFile() { + try { + return File.createTempFile("flow-dev-server", "port"); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + } + +} diff --git a/src/dorkbox/vaadin/DevModeInitializer.kt b/src/dorkbox/vaadin/devMode/DevModeInitializer.kt similarity index 93% rename from src/dorkbox/vaadin/DevModeInitializer.kt rename to src/dorkbox/vaadin/devMode/DevModeInitializer.kt index dbef56e..5bc3a69 100644 --- a/src/dorkbox/vaadin/DevModeInitializer.kt +++ b/src/dorkbox/vaadin/devMode/DevModeInitializer.kt @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ -package dorkbox.vaadin +package dorkbox.vaadin.devMode import com.vaadin.flow.component.WebComponentExporter import com.vaadin.flow.component.WebComponentExporterFactory @@ -44,7 +44,6 @@ import java.net.URLDecoder import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path -import java.nio.file.Paths import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException import java.util.concurrent.Executor @@ -65,6 +64,8 @@ import javax.servlet.annotation.WebListener * This file is NOT extensible/configurable AT-ALL, so this is required... * * + * + * * Servlet initializer starting node updaters as well as the webpack-dev-mode * server. * @@ -140,14 +141,15 @@ class DevModeInitializer : ClassLoaderAwareServletContainerInitializer, Serializ return } - - val baseDir = config.getStringProperty(FrontendUtils.PROJECT_BASEDIR, null) ?: baseDirectoryFallback + // these are BOTH set by VaadinApplication.kt + val baseDir = config.getStringProperty(FrontendUtils.PROJECT_BASEDIR, File("").absolutePath) val generatedDir = System.getProperty(FrontendUtils.PARAM_GENERATED_DIR, FrontendUtils.DEFAULT_GENERATED_DIR) - val frontendFolder = config.getStringProperty( FrontendUtils.PARAM_FRONTEND_DIR, System.getProperty(FrontendUtils.PARAM_FRONTEND_DIR, FrontendUtils.DEFAULT_FRONTEND_DIR)) + + val builder = NodeTasks.Builder(DevModeClassFinder(classes), File(baseDir), File(generatedDir), File(frontendFolder)) log().info("Starting dev-mode updaters in {} folder.", builder.npmFolder) @@ -249,35 +251,6 @@ class DevModeInitializer : ClassLoaderAwareServletContainerInitializer, Serializ return LoggerFactory.getLogger(DevModeInitializer::class.java) } - /* - * Accept user.dir or cwd as a fallback only if the directory seems to be a - * Maven or Gradle project. Check to avoid cluttering server directories - * (see tickets #8249, #8403). - */ - private val baseDirectoryFallback: String - get() { - val baseDirCandidate = System.getProperty("user.dir", ".") - val path = Paths.get(baseDirCandidate) - return if (path.toFile().isDirectory - && (path.resolve("pom.xml").toFile().exists() - || path.resolve("build.gradle").toFile().exists()) - ) { - path.toString() - } else { - throw IllegalStateException( - String.format( - "Failed to determine project directory for dev mode. " - + "Directory '%s' does not look like a Maven or " - + "Gradle project. Ensure that you have run the " - + "prepare-frontend Maven goal, which generates " - + "'flow-build-info.json', prior to deploying your " - + "application", - path.toString() - ) - ) - } - } - /* * This method returns all folders of jar files having files in the * META-INF/resources/frontend folder. We don't use URLClassLoader because diff --git a/src/dorkbox/vaadin/devMode/DevServerWatchDog.java b/src/dorkbox/vaadin/devMode/DevServerWatchDog.java new file mode 100644 index 0000000..05e9272 --- /dev/null +++ b/src/dorkbox/vaadin/devMode/DevServerWatchDog.java @@ -0,0 +1,129 @@ +/* + * Copyright 2000-2020 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package dorkbox.vaadin.devMode; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * THIS IS COPIED DIRECTLY FROM VAADIN 14.6.8 (flow 2.4.6) + * + * CHANGES FROM DEFAULT ARE MANAGED AS DIFFERENT REVISIONS. + * + * The initial commit is exactly as-is from vaadin. + * + * This file is NOT extensible/configurable AT-ALL, so this is required... + * + * + * + * + * Opens a server socket which is supposed to be opened until dev mode is active + * inside JVM. + *

+ * If this socket is closed then there is no anymore Java "client" for the + * webpack dev server and it should be stopped. + * + * @author Vaadin Ltd + * @since 2.0 + */ +class DevServerWatchDog { + + private static class WatchDogServer implements Runnable { + + private final ServerSocket server; + + WatchDogServer() { + try { + server = new ServerSocket(0); + server.setSoTimeout(0); + if (getLogger().isDebugEnabled()) { + getLogger().debug("Watchdog server has started on port {}", + server.getLocalPort()); + } + } catch (IOException e) { + throw new RuntimeException("Could not open a server socket", e); + } + } + + @Override + public void run() { + while (!server.isClosed()) { + try { + Socket accept = server.accept(); + accept.setSoTimeout(0); + enterReloadMessageReadLoop(accept); + } catch (IOException e) { + getLogger().debug( + "Error occurred during accept a connection", e); + } + } + } + + void stop() { + if (server != null) { + try { + server.close(); + } catch (IOException e) { + getLogger().debug( + "Error occurred during close the server socket", e); + } + } + } + + private Logger getLogger() { + return LoggerFactory.getLogger(WatchDogServer.class); + } + + private void enterReloadMessageReadLoop(Socket accept) throws IOException{ + BufferedReader in = new BufferedReader(new InputStreamReader( + accept.getInputStream(), StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null) { + DevModeHandler devModeHandler = DevModeHandler + .getDevModeHandler(); + if ("reload".equals(line) && devModeHandler != null + && devModeHandler.getLiveReload() != null) { + devModeHandler.getLiveReload().reload(); + } + } + } + } + + private final WatchDogServer watchDogServer; + + DevServerWatchDog() { + watchDogServer = new WatchDogServer(); + + Thread serverThread = new Thread(watchDogServer); + serverThread.setDaemon(true); + serverThread.start(); + } + + int getWatchDogPort() { + return watchDogServer.server.getLocalPort(); + } + + void stop() { + watchDogServer.stop(); + } +} diff --git a/src/dorkbox/vaadin/devMode/HandlerHelper.java b/src/dorkbox/vaadin/devMode/HandlerHelper.java new file mode 100644 index 0000000..181f759 --- /dev/null +++ b/src/dorkbox/vaadin/devMode/HandlerHelper.java @@ -0,0 +1,111 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package dorkbox.vaadin.devMode; + +import java.io.Serializable; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; + +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinSession; + +/** + * THIS IS COPIED DIRECTLY FROM VAADIN 14.6.8 (flow 2.4.6) + * + * CHANGES FROM DEFAULT ARE MANAGED AS DIFFERENT REVISIONS. + * + * The initial commit is exactly as-is from vaadin. + * + * This file is NOT extensible/configurable AT-ALL, so this is required... + */ +public class HandlerHelper implements Serializable { +// static final SystemMessages DEFAULT_SYSTEM_MESSAGES = new SystemMessages(); + static final String UNSAFE_PATH_ERROR_MESSAGE_PATTERN = "Blocked attempt to access file: {}"; + private static final Pattern PARENT_DIRECTORY_REGEX = Pattern.compile("(/|\\\\)\\.\\.(/|\\\\)?", 2); + + private HandlerHelper() { + } + + public static boolean isRequestType(VaadinRequest request, HandlerHelper.RequestType requestType) { + return requestType.getIdentifier().equals(request.getParameter("v-r")); + } + + public static Locale findLocale(VaadinSession session, VaadinRequest request) { + if (session == null) { + session = VaadinSession.getCurrent(); + } + + Locale locale; + if (session != null) { + locale = session.getLocale(); + if (locale != null) { + return locale; + } + } + + if (request == null) { + request = VaadinService.getCurrentRequest(); + } + + if (request != null) { + locale = request.getLocale(); + if (locale != null) { + return locale; + } + } + + return Locale.getDefault(); + } + + public static void setResponseNoCacheHeaders(BiConsumer headerSetter, BiConsumer longHeaderSetter) { + headerSetter.accept("Cache-Control", "no-cache, no-store"); + headerSetter.accept("Pragma", "no-cache"); + longHeaderSetter.accept("Expires", 0L); + } + + public static String getCancelingRelativePath(String pathToCancel) { + StringBuilder sb = new StringBuilder("."); + + for(int i = 1; i < pathToCancel.length(); ++i) { + if (pathToCancel.charAt(i) == '/') { + sb.append("/.."); + } + } + + return sb.toString(); + } + + static boolean isPathUnsafe(String path) { + try { + path = URLDecoder.decode(path, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException var2) { + throw new RuntimeException("An error occurred during decoding URL.", var2); + } + + return PARENT_DIRECTORY_REGEX.matcher(path).find(); + } + + public static enum RequestType { + UIDL("uidl"), + HEARTBEAT("heartbeat"), + PUSH("push"); + + private String identifier; + + private RequestType(String identifier) { + this.identifier = identifier; + } + + public String getIdentifier() { + return this.identifier; + } + } +}