All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.vaadin.base.devserver.AbstractDevServerRunner Maven / Gradle / Ivy

There is a newer version: 24.6.2
Show newest version
/*
 * Copyright 2000-2024 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 com.vaadin.base.devserver;

import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.regex.Pattern;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.vaadin.base.devserver.DevServerOutputTracker.Result;
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.internal.DevModeHandler;
import com.vaadin.flow.internal.NetworkUtil;
import com.vaadin.flow.internal.UrlUtil;
import com.vaadin.flow.router.internal.RouteUtil;
import com.vaadin.flow.server.ExecutionFailedException;
import com.vaadin.flow.server.HandlerHelper;
import com.vaadin.flow.server.HttpStatusCode;
import com.vaadin.flow.server.InitParameters;
import com.vaadin.flow.server.StaticFileServer;
import com.vaadin.flow.server.VaadinRequest;
import com.vaadin.flow.server.VaadinResponse;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.frontend.FrontendTools;
import com.vaadin.flow.server.frontend.FrontendUtils;
import com.vaadin.flow.server.startup.ApplicationConfiguration;

/**
 * Deals with most details of starting a frontend development server or
 * connecting to an existing one.
 * 

* This class is meant to be used during developing time. *

* For internal use only. May be renamed or removed in a future release. */ public abstract class AbstractDevServerRunner implements DevModeHandler { private static final String START_FAILURE = "Couldn't start dev server because"; public static final String DEV_SERVER_HOST = "http://127.0.0.1"; private static final String FAILED_MSG = "\n------------------ Frontend compilation failed. ------------------\n\n"; private static final String SUCCEED_MSG = "\n----------------- Frontend compiled successfully. -----------------\n\n"; private static final String START = "\n------------------ Starting Frontend compilation. ------------------\n"; private static final String LOG_START = "Running {} to compile frontend resources. This may take a moment, please stand by..."; /** * If after this time in millisecs, the pattern was not found, we unlock the * process and continue. It might happen if the dev server changes their * output. */ private static final String DEFAULT_TIMEOUT_FOR_PATTERN = "60000"; /** * UUID system property for identifying JVM restart. */ private static final String DEV_SERVER_PORTFILE_UUID_PROPERTY = "vaadin.frontend.devserver.portfile.uuid"; // webpack dev-server allows " character if passed through, need to // explicitly check requests for it private static final Pattern WEBPACK_ILLEGAL_CHAR_PATTERN = Pattern .compile("\"|%22"); private static final int DEFAULT_BUFFER_SIZE = 32 * 1024; private static final int DEFAULT_TIMEOUT = 120 * 1000; private final File npmFolder; private volatile int port; private final AtomicReference devServerProcess = new AtomicReference<>(); private final boolean reuseDevServer; private final File devServerPortFile; private AtomicBoolean isDevServerFailedToStart = new AtomicBoolean(); private final CompletableFuture devServerStartFuture; private final AtomicReference watchDog = new AtomicReference<>(); private boolean usingAlreadyStartedProcess = false; private ApplicationConfiguration applicationConfiguration; private String failedOutput = null; private transient Runnable waitForRestart; /** * Craete an instance that waits for the given task to complete before * starting or connecting to the server. * * @param lookup * a lookup instance * @param runningPort * the port that a dev server is already running on or 0 to start * a new server * @param npmFolder * the project root * @param waitFor * the task to wait for before running the server. */ protected AbstractDevServerRunner(Lookup lookup, int runningPort, File npmFolder, CompletableFuture waitFor) { this.npmFolder = npmFolder; port = runningPort; applicationConfiguration = lookup .lookup(ApplicationConfiguration.class); reuseDevServer = applicationConfiguration.reuseDevServer(); devServerPortFile = getDevServerPortFile(npmFolder); BiConsumer action = (value, exception) -> { // this will throw an exception if an exception has been thrown by // the waitFor task waitFor.getNow(null); runOnFutureComplete(); }; devServerStartFuture = waitFor.whenCompleteAsync(action); } private void runOnFutureComplete() { try { doStartDevModeServer(); } catch (ExecutionFailedException exception) { getLogger().error(null, exception); throw new CompletionException(exception); } } void doStartDevModeServer() throws ExecutionFailedException { waitForRestart = DevServerOutputTracker.activeServerRestartGuard(); if (waitForRestart != null) { getLogger().debug("RestartMonitor is active"); } // If port is defined, means that the dev server is already running if (port > 0) { if (!checkConnection()) { throw new IllegalStateException(String.format( "%s %s port '%d' is defined but it's not working properly", getServerName(), START_FAILURE, port)); } reuseExistingPort(port); return; } port = getRunningDevServerPort(npmFolder); if (port > 0) { if (checkConnection()) { reuseExistingPort(port); return; } else { getLogger().warn(String.format( "%s port '%d' is defined but it's not working properly. Using a new free port...", getServerName(), port)); port = 0; } } // here the port == 0 validateFiles(); long start = System.nanoTime(); getLogger().info("Starting " + getServerName()); watchDog.set(new DevServerWatchDog()); // Look for a free port port = NetworkUtil.getFreePort(); // save the port immediately before start a dev server, see #8981 saveRunningDevServerPort(); try { Process process = doStartDevServer(); devServerProcess.set(process); if (!isRunning()) { throw new IllegalStateException("Startup of " + getServerName() + " failed. Output was:\n" + getFailedOutput()); } long ms = (System.nanoTime() - start) / 1000000; getLogger().info("Started {}. Time: {}ms", getServerName(), ms); } finally { if (devServerProcess.get() == null) { removeRunningDevServerPort(); } } } /** * Validates that the needed server binary and config file(s) are available. * * @throws ExecutionFailedException * if there is a problem */ protected void validateFiles() throws ExecutionFailedException { assert getPort() == 0; // Skip checks if we have a dev server already running File binary = getServerBinary(); File config = getServerConfig(); if (!getProjectRoot().exists()) { getLogger().warn("No project folder '{}' exists", getProjectRoot()); throw new ExecutionFailedException(START_FAILURE + " the target execution folder doesn't exist."); } if (!binary.exists()) { getLogger().warn("'{}' doesn't exist. Did you run `npm install`?", binary); throw new ExecutionFailedException(String.format( "%s '%s' doesn't exist. `npm install` has not run or failed.", START_FAILURE, binary)); } else if (!binary.canExecute()) { getLogger().warn( " '{}' is not an executable. Did you run `npm install`?", binary); throw new ExecutionFailedException(String.format( "%s '%s' is not an executable." + " `npm install` has not run or failed.", START_FAILURE, binary)); } if (!config.canRead()) { getLogger().warn( "{} configuration '{}' is not found or is not readable.", getServerName(), config); throw new ExecutionFailedException( String.format("%s '%s' doesn't exist or is not readable.", START_FAILURE, config)); } } /** * Gets the binary that starts the dev server. */ protected abstract File getServerBinary(); /** * Gets the main configuration file for the dev server. */ protected abstract File getServerConfig(); /** * Gets the name of the dev server for outputting to the user and * statistics. */ protected abstract String getServerName(); /** * Gets the commands to run to start the dev server. * * @param tools * the frontend tools object */ protected abstract List getServerStartupCommand( FrontendTools tools); /** * Defines the environment variables to use when starting the dev server. * * @param frontendTools * frontend tools metadata * @param environment * the environment variables to use */ protected void updateServerStartupEnvironment(FrontendTools frontendTools, Map environment) { environment.put("watchDogHost", getLoopbackAddress().getHostAddress()); environment.put("watchDogPort", Integer.toString(getWatchDog().getWatchDogPort())); } // visible for tests InetAddress getLoopbackAddress() { return InetAddress.getLoopbackAddress(); } /** * Gets a pattern to match with the output to determine that the server has * started successfully. */ protected abstract Pattern getServerSuccessPattern(); /** * Gets a pattern to match with the output to determine that the server has * failed to start. */ protected abstract Pattern getServerFailurePattern(); /** * Gets a pattern to match with the output to determine that the server is * restarting. * * Defaults to {@literal null}, meaning that server restart is not * monitored. * * Server restart is monitored only if both this method and * {@link #getServerRestartedPattern()} provides a pattern. */ protected Pattern getServerRestartingPattern() { return null; } /** * Gets a pattern to match with the output to determine that the server has * been restarted. * * Defaults to {@literal null}, meaning that server restart is not * monitored. * * Server restart is monitored only if both this method and * {@link #getServerRestartingPattern()} provides a pattern. */ protected Pattern getServerRestartedPattern() { return null; } /** * Starts the dev server and returns the started process. * * @return the started process or {@code null} if no process was started */ protected Process doStartDevServer() { ApplicationConfiguration config = getApplicationConfiguration(); ProcessBuilder processBuilder = new ProcessBuilder() .directory(getProjectRoot()); FrontendTools tools = new FrontendTools(config, getProjectRoot()); tools.validateNodeAndNpmVersion(); List command = getServerStartupCommand(tools); FrontendUtils.console(FrontendUtils.GREEN, START); if (getLogger().isDebugEnabled()) { getLogger().debug(FrontendUtils.commandToString( getProjectRoot().getAbsolutePath(), command)); } processBuilder.command(command); Map environment = processBuilder.environment(); updateServerStartupEnvironment(tools, environment); try { Process process = processBuilder.redirectErrorStream(true).start(); /* * We only can save the dev server process 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 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)); DevServerOutputTracker outputTracker = new DevServerOutputTracker( process.getInputStream(), getServerSuccessPattern(), getServerFailurePattern(), this::onDevServerCompilation); Pattern restartingPattern = getServerRestartingPattern(); Pattern restartedPattern = getServerRestartedPattern(); if (restartingPattern != null && restartedPattern != null) { waitForRestart = outputTracker.serverRestartGuard( restartingPattern, restartedPattern); getLogger().debug("RestartMonitor is active"); } else { getLogger().trace( "RestartMonitor not active. Both restarting and restarted pattern are required"); } outputTracker.find(); getLogger().info(LOG_START, getServerName()); int timeout = Integer.parseInt(config.getStringProperty( InitParameters.SERVLET_PARAMETER_DEVMODE_TIMEOUT, DEFAULT_TIMEOUT_FOR_PATTERN)); outputTracker.awaitFirstMatch(timeout); return process; } catch (IOException e) { getLogger().error( "Failed to start the " + getServerName() + " process", e); } catch (InterruptedException e) { getLogger().debug( getServerName() + " process start has been interrupted", e); } return null; } /** * Called whenever the dev server output matche the success or failure * pattern. */ protected void onDevServerCompilation(Result result) { if (result.isSuccess()) { FrontendUtils.console(FrontendUtils.GREEN, SUCCEED_MSG); failedOutput = null; } else { FrontendUtils.console(FrontendUtils.RED, FAILED_MSG); failedOutput = result.getOutput(); } } @Override public String getFailedOutput() { return failedOutput; } /** * Gets the server watch dog. * * @return the watch dog */ protected DevServerWatchDog getWatchDog() { return watchDog.get(); } @Override public File getProjectRoot() { return npmFolder; } /** * Gets the application configuration. * * @return the application configuration */ protected ApplicationConfiguration getApplicationConfiguration() { return applicationConfiguration; } /** * Check the connection to the dev server. * * @return {@code true} if the dev server is responding correctly, * {@code false} otherwise */ protected boolean checkConnection() { try { HttpURLConnection connection = prepareConnection("/index.html", "GET"); return connection.getResponseCode() == HttpURLConnection.HTTP_OK; } catch (IOException e) { getLogger().debug("Error checking dev server connection", e); } return false; } private static int getRunningDevServerPort(File npmFolder) { int port = 0; File portFile = getDevServerPortFile(npmFolder); 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; } /** * Remove the running port from the vaadinContext and temporary file. */ private void removeRunningDevServerPort() { FileUtils.deleteQuietly(devServerPortFile); } @Override public int getPort() { return port; } private void reuseExistingPort(int port) { getLogger().info("Reusing {} running at {}:{}", getServerName(), DEV_SERVER_HOST, port); this.usingAlreadyStartedProcess = true; // Save running port for next usage saveRunningDevServerPort(); watchDog.set(null); } private void saveRunningDevServerPort() { try { FileUtils.writeStringToFile(devServerPortFile, String.valueOf(port), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); } } private static File getDevServerPortFile(File npmFolder) { // UUID changes between JVM restarts String jvmUuid = System.getProperty(DEV_SERVER_PORTFILE_UUID_PROPERTY); if (jvmUuid == null) { jvmUuid = UUID.randomUUID().toString(); System.setProperty(DEV_SERVER_PORTFILE_UUID_PROPERTY, jvmUuid); } // Frontend path ensures uniqueness for multiple devmode apps running // simultaneously String frontendBuildPath = npmFolder.getAbsolutePath(); String uniqueUid = UUID.nameUUIDFromBytes( (jvmUuid + frontendBuildPath).getBytes(StandardCharsets.UTF_8)) .toString(); return new File(System.getProperty("java.io.tmpdir"), uniqueUid); } /** * Waits for the dev server to start. *

* Suspends the caller's thread until the dev mode server is started (or * failed to start). */ public void waitForDevServer() { devServerStartFuture.join(); } boolean isRunning() { Process process = devServerProcess.get(); return (process != null && process.isAlive()) || usingAlreadyStartedProcess; } @Override public void stop() { if (reuseDevServer) { return; } try { // The most reliable way to stop the dev server is // by informing it to exit. We have implemented // a listener that handles the stop command via HTTP and exits. prepareConnection("/stop", "GET").getResponseCode(); } catch (IOException e) { getLogger().debug( getServerName() + " does not support the `/stop` command.", e); } DevServerWatchDog watchDogInstance = watchDog.get(); if (watchDogInstance != null) { watchDogInstance.stop(); } Process process = devServerProcess.get(); if (process != null && process.isAlive()) { process.destroy(); } devServerProcess.set(null); usingAlreadyStartedProcess = false; removeRunningDevServerPort(); } @Override public HttpURLConnection prepareConnection(String path, String method) throws IOException { if (waitForRestart != null) { waitForRestart.run(); } // path should have been checked at this point for any outside requests URL uri = new URL(DEV_SERVER_HOST + ":" + getPort() + path); HttpURLConnection connection = (HttpURLConnection) uri.openConnection(); connection.setRequestMethod(method); connection.setReadTimeout(DEFAULT_TIMEOUT); connection.setConnectTimeout(DEFAULT_TIMEOUT); return connection; } @Override public boolean handleRequest(VaadinSession session, VaadinRequest request, VaadinResponse response) throws IOException { return handleRequestInternal(session, request, response, devServerStartFuture, isDevServerFailedToStart); } static boolean handleRequestInternal(VaadinSession session, VaadinRequest request, VaadinResponse response, CompletableFuture devServerStartFuture, AtomicBoolean isDevServerFailedToStart) throws IOException { if (devServerStartFuture.isDone()) { // The server has started, check for any exceptions in the startup // process try { devServerStartFuture.getNow(null); } catch (CompletionException exception) { isDevServerFailedToStart.set(true); throw getCause(exception); } if (request.getHeader("X-DevModePoll") != null) { // Avoid creating a UI that is thrown away for polling requests response.setContentType("text/html;charset=utf-8"); response.getWriter().write("Ready"); response.setHeader("Cache-Control", "no-cache"); return true; } try { session.getLockInstance().lock(); VaadinService service = session.getService(); RouteUtil.checkForClientRouteCollisions(service, service .getRouter().getRegistry().getRegisteredRoutes()); } finally { session.getLockInstance().unlock(); } return false; } else { if (request.getHeader("X-DevModePoll") == null) { // The initial request while the dev server is starting InputStream inputStream = AbstractDevServerRunner.class .getResourceAsStream("dev-mode-not-ready.html"); IOUtils.copy(inputStream, response.getOutputStream()); } else { // A polling request while the server is starting response.getWriter().write("Pending"); } response.setContentType("text/html;charset=utf-8"); response.setHeader("X-DevModePending", "true"); response.setHeader("Cache-Control", "no-cache"); return true; } } /** * Serve a file by proxying to the dev server. *

* Note: it considers the {@link HttpServletRequest#getPathInfo} that will * be the path passed to the 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 the dev server returned a not found, true otherwise * @throws IOException * in the case something went wrong like connection refused */ @Override 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() || devServerStartFuture.isCompletedExceptionally()) { return false; } // Since we have 'publicPath=/VAADIN/' in the dev server config, // a valid request for the dev server should start with '/VAADIN/' String requestFilename = request.getPathInfo(); if (HandlerHelper.isPathUnsafe(requestFilename) || WEBPACK_ILLEGAL_CHAR_PATTERN.matcher(requestFilename) .find()) { getLogger().info("Blocked attempt to access file: {}", requestFilename); response.setStatus(HttpStatusCode.FORBIDDEN.getCode()); return true; } // Redirect theme source request if (StaticFileServer.APP_THEME_ASSETS_PATTERN.matcher(requestFilename) .find()) { requestFilename = "/VAADIN/static" + requestFilename; } if (requestFilename.equals("") || requestFilename.equals("/")) { // Index file must be handled by IndexHtmlRequestHandler return false; } String devServerRequestPath = UrlUtil.encodeURI(requestFilename); if (request.getQueryString() != null) { devServerRequestPath += "?" + request.getQueryString(); } HttpURLConnection connection = prepareConnection(devServerRequestPath, 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 from {} {}", getServerName(), connection.getURL()); int responseCode = connection.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { getLogger().debug("Resource not served by {} {}", getServerName(), devServerRequestPath); // the dev server cannot access the resource, return false so Flow // can // handle it return false; } getLogger().debug("Served resource by {}: {} {}", getServerName(), responseCode, devServerRequestPath); // Copies response headers connection.getHeaderFields().forEach((header, values) -> { if (header != null) { if ("Transfer-Encoding".equals(header)) { return; } response.addHeader(header, values.get(0)); } }); if (requestFilename .startsWith("/VAADIN/generated/jar-resources/copilot/")) { // Cache copilot files as they have a generated hash at the end response.setHeader("Cache-Control", "max-age=31536001,immutable"); } if (responseCode == HttpURLConnection.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 static 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); } } protected 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() { return LoggerFactory.getLogger(AbstractDevServerRunner.class); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy