finalChildren = new ArrayList<>(e.getChildren());
finalChildren.add(e);
- CachedProject t = new CachedProject(diskCache, project.getArtifact(), project, finalChildren, Collections.singletonList(tmp.toString()), Collections.emptyList());
+ CachedProject t = new CachedProject(diskCache, project.getArtifact(), project, finalChildren, Collections.singletonList(tmp.toString()), Collections.emptyList(), null);
TestConfig config = new TestConfig(testClass, this);
// build this project normally
diff --git a/src/main/java/net/cardosi/mojo/WatchMojo.java b/src/main/java/net/cardosi/mojo/WatchMojo.java
index 406cf2e6..d77f733b 100644
--- a/src/main/java/net/cardosi/mojo/WatchMojo.java
+++ b/src/main/java/net/cardosi/mojo/WatchMojo.java
@@ -1,9 +1,17 @@
package net.cardosi.mojo;
import com.google.javascript.jscomp.DependencyOptions;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
import net.cardosi.mojo.cache.CachedProject;
import net.cardosi.mojo.cache.DiskCache;
import net.cardosi.mojo.cache.TranspiledCacheEntry;
+import net.cardosi.mojo.tools.DevServer;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.model.Plugin;
import org.apache.maven.model.PluginExecution;
@@ -19,12 +27,6 @@
import org.apache.maven.project.ProjectBuildingRequest;
import org.codehaus.plexus.util.xml.Xpp3Dom;
-import java.io.File;
-import java.io.IOException;
-import java.util.*;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
-
/**
* Attempts to do the setup for various test and build goals declared in the current project or in child projects,
* but also allows the configuration for this goal to further customize them. For example, this goal will be
@@ -91,8 +93,39 @@ public class WatchMojo extends AbstractBuildMojo {
@Parameter(defaultValue = "false")
protected boolean rewritePolyfills;
- @Parameter(defaultValue = "false")
- protected boolean enableSourcemaps;
+ /**
+ * Enable the live-reloading dev server
+ */
+ @Parameter(defaultValue = "true", property = "devServerEnable")
+ protected boolean devServerEnable;
+
+ /**
+ * Port for the dev server to operate
+ */
+ @Parameter(defaultValue = "8085", property = "devServerPort")
+ protected int devServerPort;
+
+ /**
+ * The base href from which your application will be deployed
+ * (and therefore, should be tested on). For example, if you will deploy
+ * your app to myserver.com/my-app/, set devServerBaseHref=/my-app.
+ * This way, requested resources will be served correctly. The default
+ * value is '/'.
+ *
+ * Note that using the {@code } tag in index.html is a best practice
+ * to allow relative hrefs.
+ */
+ @Parameter(defaultValue = "/", property = "devServerBaseHref")
+ protected String devServerBaseHref;
+
+ /**
+ * The 'main' artifact-id for this project that has the index.html
+ * and other sources to host. If not configured, we try to pick the
+ * first artifact with a `src/main/webapp/index.html`, defaulting
+ * to {@link #webappDirectory}.
+ */
+ @Parameter(property = "devServerRootArtifactId")
+ protected String devServerRootArtifactId;
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
@@ -180,7 +213,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
// apps.add(e);
} else if (goal.equals("build") && shouldCompileBuild()) {
System.out.println("Found build " + execution);
- XmlDomClosureConfig config = new XmlDomClosureConfig(configuration, Artifact.SCOPE_COMPILE_PLUS_RUNTIME, compilationLevel, rewritePolyfills, reactorProject.getArtifactId(), DependencyOptions.DependencyMode.SORT_ONLY, enableSourcemaps, webappDirectory);
+ XmlDomClosureConfig config = new XmlDomClosureConfig(configuration, Artifact.SCOPE_COMPILE_PLUS_RUNTIME, compilationLevel, rewritePolyfills, reactorProject.getArtifactId(), DependencyOptions.DependencyMode.SORT_ONLY, false, webappDirectory);
// Load up all the dependencies in the requested scope for the current project
CachedProject p = loadDependenciesIntoCache(reactorProject.getArtifact(), reactorProject, true, projectBuilder, request, diskCache, pluginVersion, projects, Artifact.SCOPE_COMPILE_PLUS_RUNTIME, getDependencyReplacements(), "* ");
@@ -204,23 +237,59 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
diskCache.release();
+ DevServer devServer = null;
+ if (devServerEnable) {
+ Path devServerRoot;
+ if (devServerRootArtifactId != null) {
+ devServerRoot = Paths.get(webappDirectory).resolve(devServerRootArtifactId);
+ } else {
+ // could not find index.html... default to webappDirectory
+ devServerRoot = Paths.get(webappDirectory);
+
+ /*
+ if we can find a webapps/index.html, lets use that
+ instead of webappDirectory.
+ */
+ for (MavenProject project : reactorProjects) {
+ Path indexHtml = Paths.get(project.getBasedir().toPath().toAbsolutePath() +
+ "/src/main/webapp/index.html");
+ if (Files.exists(indexHtml)) {
+ devServerRoot = Paths.get(webappDirectory).resolve(project.getArtifactId());
+ break;
+ }
+ }
+ }
+
+ devServer = new DevServer(devServerRoot, devServerBaseHref, devServerPort);
+
+ // initial build
+ devServer.notifyBuilding();
+ DevServer finalDevServer = devServer;
+ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+ .thenRun(finalDevServer::notifyBuildStepComplete);
+ }
+
for (CachedProject app : projects.values()) {
//TODO instead of N threads per project, combine threads?
try {
- app.watch();
+ app.watch(webappDirectory, devServer);
} catch (IOException ex) {
ex.printStackTrace();
//TODO fall back to polling or another strategy
}
}
- // TODO replace this dumb timer with a System.in loop so we can watch for some commands from the user
- try {
- Thread.sleep(TimeUnit.MINUTES.toMillis(30));
- } catch (InterruptedException e) {
- e.printStackTrace();
+ if (devServerEnable) {
+ new Thread(devServer).start();
}
+ // Any user input will stop watch goal.
+ try {
+ System.out.println("Press any key to stop watching");
+ System.in.read();
+ } catch (IOException e) {
+ throw new MojoExecutionException("Error awaiting user input: " + e.getMessage());
+ }
}
private Xpp3Dom merge(Xpp3Dom pluginConfiguration, Xpp3Dom configuration) {
diff --git a/src/main/java/net/cardosi/mojo/cache/CachedProject.java b/src/main/java/net/cardosi/mojo/cache/CachedProject.java
index 0cb3692c..bc817486 100644
--- a/src/main/java/net/cardosi/mojo/cache/CachedProject.java
+++ b/src/main/java/net/cardosi/mojo/cache/CachedProject.java
@@ -5,6 +5,7 @@
import com.google.gson.GsonBuilder;
import com.google.j2cl.common.FrontendUtils;
import com.google.javascript.jscomp.*;
+import com.sun.nio.file.SensitivityWatchEventModifier;
import net.cardosi.mojo.ClosureBuildConfiguration;
import net.cardosi.mojo.Hash;
import net.cardosi.mojo.tools.*;
@@ -14,6 +15,7 @@
import org.apache.maven.artifact.resolver.filter.ScopeArtifactFilter;
import org.apache.maven.model.FileSet;
import org.apache.maven.model.Resource;
+import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
import java.io.File;
@@ -62,20 +64,22 @@ private enum Step {
private final List dependents = new ArrayList<>();
private final List compileSourceRoots;
private final List resources;
+ private final Path webappPath;
private final Map> steps = new ConcurrentHashMap<>();
private Set>> registeredBuildTerminals = new HashSet<>();
- public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List children, List compileSourceRoots, List resources) {
+ public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List children, List compileSourceRoots, List resources, Path webappPath) {
this.diskCache = diskCache;
this.compileSourceRoots = compileSourceRoots;
this.resources = resources;
+ this.webappPath = webappPath;
replace(artifact, currentProject, children);
}
- public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List children) {
- this(diskCache, artifact, currentProject, children, currentProject.getCompileSourceRoots(), currentProject.getResources());
+ public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List children, Path webappPath) {
+ this(diskCache, artifact, currentProject, children, currentProject.getCompileSourceRoots(), currentProject.getResources(), webappPath);
}
public void replace(Artifact artifact, MavenProject currentProject, List children) {
@@ -97,11 +101,8 @@ public void replace(Artifact artifact, MavenProject currentProject, List markDirty() {
synchronized (steps) {
- if (steps.isEmpty()) {
- return;
- }
// cancel all running work
for (CompletableFuture cf : steps.values()) {
try {
@@ -124,9 +125,7 @@ public void markDirty() {
dependents.forEach(CachedProject::markDirty);
//TODO cache those "compile me" or "test me" values so we don't pass around like this
- if (!registeredBuildTerminals.isEmpty()) {
- build();
- }
+ return build();
}
//TODO instead of these, consider a .test() method instead?
@@ -164,43 +163,200 @@ public boolean hasSourcesMapped() {
return !compileSourceRoots.isEmpty();
}
- public void watch() throws IOException {
- Map> fileSystemsToWatch = compileSourceRoots.stream().map(Paths::get).collect(Collectors.groupingBy(Path::getFileSystem));
+ public void watch(String webappDirectory, DevServer devServer) throws IOException {
+ /*
+ First, make a thread to watch changes to `src/main/webapp`, if configured.
+ */
+ if (webappPath != null) {
+ // initial copy
+ Path outDir = Paths.get(webappDirectory).resolve(getArtifactId());
+ FileUtils.copyDirectory(webappPath.toFile(), outDir.toFile());
+
+ WatchService ws = webappPath.getFileSystem().newWatchService();
+ registerDirectories(webappPath, ws);
+
+ new Thread(() -> {
+ while (true) {
+ try {
+ WatchKey key = ws.take();
+ List> events = key.pollEvents();
+
+ if (devServer != null) {
+ devServer.notifyBuilding();
+ }
+
+ for (WatchEvent> event : events) {
+ WatchEvent.Kind> kind = event.kind();
+
+ if (kind == StandardWatchEventKinds.OVERFLOW) {
+ // need to redo everything, since events have been
+ // lost / discarded and therefore we're not up to date.
+ System.err.println("OVERFLOW event occurred, this should not happen often.");
+ FileUtils.deleteDirectory(outDir.toFile());
+ try {
+ markDirty().join();
+ } catch (Exception e) {
+ /*
+ we do not want to interrupt the thread, since we
+ want to try building again when the user fixes the problem
+ */
+ e.printStackTrace();
+ }
+ FileUtils.copyDirectory(webappPath.toFile(), outDir.toFile());
+ // ignore remaining events
+ break;
+
+ } else if (kind == StandardWatchEventKinds.ENTRY_CREATE ||
+ kind == StandardWatchEventKinds.ENTRY_MODIFY) {
+ if (isTempFileEvent(event))
+ continue;
+
+ /*
+ if new directory was created, register it
+ no need to be recursive, since there will be a new
+ WatchEvent for every sub-directory created.. we can
+ just register one-by-one.
+ */
+ Path toCopy = (Path) event.context();
+ if (kind == StandardWatchEventKinds.ENTRY_CREATE && Files.isDirectory(toCopy)) {
+ toCopy.register(ws, WATCH_KINDS, WATCH_MODIFIERS);
+ }
+
+ Files.copy(webappPath.resolve(toCopy), outDir.resolve(toCopy),
+ StandardCopyOption.REPLACE_EXISTING);
+
+ } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
+ if (isTempFileEvent(event))
+ continue;
+
+ Path toDelete = (Path) event.context();
+ Files.deleteIfExists(outDir.resolve(toDelete));
+ }
+ }
+
+ if (!key.reset()) {
+ throw new MojoExecutionException("The src/main/webapp WatchService is no longer valid");
+ }
+
+ if (devServer != null) {
+ devServer.notifyBuildStepComplete();
+ }
+
+ } catch (InterruptedException | IOException | MojoExecutionException e) {
+ e.printStackTrace();
+ // todo: is this the best error handling we can do?
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }).start();
+ }
+
+ /*
+ Next, make a thread to watch every compileSourceRoot, and rebuild on change.
+ */
+ Map> fileSystemsToWatch = compileSourceRoots.stream()
+ .map(Paths::get)
+ .collect(Collectors.groupingBy(Path::getFileSystem));
+
for (Map.Entry> entry : fileSystemsToWatch.entrySet()) {
WatchService watchService = entry.getKey().newWatchService();
- for (Path path : entry.getValue()) {
- Files.walkFileTree(path, new SimpleFileVisitor() {
- @Override
- public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes basicFileAttributes) throws IOException {
- path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY);
- return FileVisitResult.CONTINUE;
- }
- });
- path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY);
+ for (Path sourceRoot : entry.getValue()) {
+ registerDirectories(sourceRoot, watchService);
}
- new Thread() {
- @Override
- public void run() {
- while (true) {
- try {
- WatchKey key = watchService.poll(10, TimeUnit.SECONDS);
- if (key == null) {
- continue;
+ new Thread(() -> {
+ while (true) {
+ try {
+ WatchKey key = watchService.take();
+ List> events = key.pollEvents();
+
+ if (devServer != null) {
+ devServer.notifyBuilding();
+ }
+
+ /*
+ if new directory was created, register it
+ no need to be recursive, since there will be a new
+ WatchEvent for every sub-directory created.. we can
+ just register one-by-one.
+ */
+ for (WatchEvent> event : events) {
+ if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE) {
+ Path dirToWatch = (Path) event.context();
+ if (Files.isDirectory(dirToWatch)) {
+ dirToWatch.register(watchService, WATCH_KINDS, WATCH_MODIFIERS);
+ }
}
- //TODO if it was a create, register it (recursively?)
- key.pollEvents();//clear the events out
- key.reset();//reset to go again
- markDirty();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
}
+
+ if (!key.reset()) {
+ throw new MojoExecutionException("The WatchService is no longer valid");
+ }
+
+ /*
+ run through build steps
+
+ we do not want to interrupt the thread, since we
+ want to try building again when the user fixes the problem
+ */
+ try {
+ markDirty().join();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ if (devServer != null) {
+ devServer.notifyBuildStepComplete();
+ }
+
+ } catch (InterruptedException | IOException | MojoExecutionException e) {
+ e.printStackTrace();
+ Thread.currentThread().interrupt();
+ return;
}
}
- }.start();
+ }).start();
}
}
+ private static boolean isTempFileEvent(WatchEvent> event) {
+ if (event.kind() != StandardWatchEventKinds.OVERFLOW) {
+ Path p = (Path) event.context();
+ // ignore Vim & Intellij backup files
+ // todo any other filetypes we can ignore?
+ return p.getFileName().toString().endsWith("~");
+ }
+
+ return false;
+ }
+
+ private static final WatchEvent.Kind[] WATCH_KINDS = {
+ StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_DELETE,
+ StandardWatchEventKinds.ENTRY_MODIFY
+ };
+
+ private static final WatchEvent.Modifier[] WATCH_MODIFIERS =
+ { SensitivityWatchEventModifier.HIGH };
+
+ /**
+ * Register Directory dir and all sub-Directories with the provided WatchService.
+ *
+ * Directories are registered with {ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY}
+ * keys, with a HIGH sensitivity (needed for MacOs, which does not have a native
+ * WatchService impl, and is very slow otherwise).
+ */
+ private static void registerDirectories(Path dir, WatchService ws) throws IOException {
+ Files.walkFileTree(dir, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
+ dir.register(ws, WATCH_KINDS, WATCH_MODIFIERS);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ dir.register(ws, WATCH_KINDS, WATCH_MODIFIERS);
+ }
+
public CompletableFuture registerAsApp(ClosureBuildConfiguration config) {
Supplier> supplier = () -> jscompWithScope(config);
registeredBuildTerminals.add(supplier);
@@ -924,6 +1080,9 @@ private CompletableFuture hash() {
for (String compileSourceRoot : compileSourceRoots) {
appendHashOfAllSources(hash, Paths.get(compileSourceRoot));
}
+// if (webappPath != null) {
+// appendHashOfAllSources(hash, webappPath);
+// }
} else {
try (FileSystem zip = FileSystems.newFileSystem(URI.create("jar:" + getArtifact().getFile().toURI()), Collections.emptyMap())) {
for (Path rootDirectory : zip.getRootDirectories()) {
diff --git a/src/main/java/net/cardosi/mojo/tools/DevServer.java b/src/main/java/net/cardosi/mojo/tools/DevServer.java
new file mode 100644
index 00000000..e8f5d782
--- /dev/null
+++ b/src/main/java/net/cardosi/mojo/tools/DevServer.java
@@ -0,0 +1,558 @@
+package net.cardosi.mojo.tools;
+
+import java.awt.*;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CharsetEncoder;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
+import java.util.Base64;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Phaser;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * This class serves resources to the client and manages live-reloading.
+ * If a user requests any resource via GET, it is resolved against {@link #root()}
+ * and served as is. The one exception is index.html, which is handled as follows:
+ *
+ *
+ * - User requests index.html.
+ * - We inject some JS into the page, and serve.
+ * - The injected JS initiates a websocket connection with the server.
+ * - We store this socket connection in a queue.
+ * - When some source-file changes eventually trigger reload(), we poll() every
+ * connection and send a reload message.
+ * - The injected JS receives this message and reloads the page. Loop to 1.
+ *
+ */
+public class DevServer implements Runnable {
+
+ // we inject some javascript after this tag in index.html
+ private static final String BODY_TAG = "";
+ private static final int BODY_TAG_LEN = BODY_TAG.length();
+
+ // webserver root
+ private final Path root;
+ // base href to deploy and test on
+ private final String baseHref;
+ private final Pattern baseHrefPattern;
+ // path to index.html, whether it currently exists or not
+ private final Path indexHtmlPath;
+ // webserver port
+ private final int port;
+ // JS injected into index.html, triggers web socket initialization and reload
+ private final ByteBuffer encodedJsBuf;
+ // buffer used for serving requests and responses
+ private final ByteBuffer buffer = ByteBuffer.allocate(250_000);
+ // see class javadoc
+ private final ConcurrentLinkedQueue webSockets = new ConcurrentLinkedQueue<>();
+ /**
+ * This Phaser determines when to reload() the tabs. Using Phaser solves the problem
+ * of reloading before all build steps have finished. Consider:
+ *
+ * - User saves their IDE, saving {@code module1/src/main/java/Main.java} and
+ * {@code module2/src/main/java/Util.java} at the same time.
+ * - Thread A is running the WatchService for module1. The thread
+ * calls {@link #notifyBuilding()} and begins the (long) build.
+ * - Thread B is running the WatchService for module2. The thread
+ * calls {@link #notifyBuilding()} and begins the (short) build.
+ * - Thread B finishes and calls {@link #notifyBuildStepComplete()}.
+ * - Thread A finishes and calls {@link #notifyBuildStepComplete()}
+ * - All builds are now finished, triggering Phaser#onAdvance, which
+ * calls reload().
+ *
+ *
+ * Note: This is a classic CyclicBarrier problem. Phaser is perfect because, unlike
+ * CyclicBarrier, the number of registered parties can be dynamic.
+ */
+ private final Phaser phaser = new Phaser() {
+ @Override
+ protected boolean onAdvance(int phase, int registeredParties) {
+ reload();
+ return false;
+ }
+ };
+ // the initial websocket message.
+ private final ByteBuffer initMsgBuffer = ByteBuffer.wrap(encodeWSMsg("init"));
+ // websocket message triggering reload.
+ private final ByteBuffer reloadMsgBuffer = ByteBuffer.wrap(encodeWSMsg("reloadplz"));
+ // we assume utf-8 encoding, like elsewhere in this plugin
+ private final CharsetEncoder utf8Encoder = UTF_8.newEncoder();
+ private final CharsetDecoder utf8Decoder = UTF_8.newDecoder();
+
+ /**
+ * @param root Path to host files from
+ * @param baseHref Equivalent to {@code }
+ * @param port Port to bind on localhost
+ */
+ public DevServer(Path root, String baseHref, int port) {
+ this.root = root;
+
+ // make sure baseHref starts and ends with '/'
+ if (!baseHref.equals("/")) {
+ if (!baseHref.startsWith("/")) {
+ baseHref = "/" + baseHref;
+ }
+ if (!baseHref.endsWith("/")) {
+ baseHref += "/";
+ }
+ }
+ this.baseHref = baseHref;
+ baseHrefPattern = Pattern.compile("^" + baseHref);
+
+ indexHtmlPath = root.resolve("index.html");
+
+ this.port = port;
+
+ String js = "";
+ encodedJsBuf = UTF_8.encode(js);
+ }
+
+ /**
+ * Content root. Configured during construction.
+ */
+ public Path root() {
+ return root;
+ }
+
+ /**
+ * Notifies this server that a build-action has
+ * started, and it is unsafe to perform a
+ * reload.
+ */
+ public void notifyBuilding() {
+ phaser.register();
+ }
+
+ /**
+ * Notifies this server that a build-action has
+ * completed. Once all other pending build-actions
+ * have also called notifyBuildComplete(), a
+ * reload will be triggered.
+ */
+ public void notifyBuildStepComplete() {
+ phaser.arriveAndDeregister();
+ }
+
+ private void reload() {
+ System.out.println("Files changed, reloading...");
+
+ try {
+ SocketChannel s;
+ while ((s = webSockets.poll()) != null) {
+ while (reloadMsgBuffer.hasRemaining()) s.write(reloadMsgBuffer);
+ s.close();
+ reloadMsgBuffer.rewind();
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ reloadMsgBuffer.rewind();
+ }
+ }
+
+ /**
+ * Starts the server
+ */
+ @Override
+ public void run() {
+ try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
+ ssc.bind(new InetSocketAddress(port));
+
+ // open the browser to localhost, if supported.
+ Desktop desktop;
+ if (Files.exists(indexHtmlPath) &&
+ Desktop.isDesktopSupported() &&
+ (desktop = Desktop.getDesktop()).isSupported(Desktop.Action.BROWSE)) {
+ desktop.browse(URI.create("http://localhost:" + port + baseHref));
+ }
+
+ while (true) {
+ SocketChannel sc = ssc.accept();
+
+ // the resource to serve
+ Path res;
+ // whether to insert our websocket JS
+ boolean servingIndexHtml = true;
+
+ /*
+ Read request bytes into the buffer. Since this is a simple dev server,
+ we only need to support small GET requests.
+ */
+ buffer.clear();
+ sc.read(buffer);
+ buffer.flip();
+
+
+ /*
+ the resource requested, ie `/home` or `/css/styles.css`. Note that
+ getInBetween advances buffer, which is fine.. there is a required
+ ordering to the headers we care about, with request type coming first.
+ */
+ String req = getInBetween("GET ", " ");
+ if (req == null) {
+ sc.close();
+ System.err.println("Malformed Request.. could not find GET header");
+ continue;
+ }
+
+ /*
+ Remove baseHref from request, if found
+ */
+ Matcher baseHrefMatcher = baseHrefPattern.matcher(req);
+ if (baseHrefMatcher.find()) {
+ req = baseHrefMatcher.replaceFirst("/");
+ }
+
+ /*
+ Now we switch over a bunch of request cases. The client could ask
+ for a resource, index.html, websocket connection, source map, etc.
+
+ First up, if the request is for '/', set res = index.html.
+ */
+ if (req.equals("/")) {
+ res = indexHtmlPath;
+
+ } else if (req.equals("/_serveWebsocket")) {
+ sc.socket().setKeepAlive(true);
+
+ /*
+ we search the header for WebSocket Key,
+ perform the protocol switch, and
+ send an init message.
+ */
+ String wsKey = getInBetween("Sec-WebSocket-Key: ", "\r\n");
+ if (wsKey == null) {
+ sc.close();
+ System.err.println("Could not find websocket key");
+ continue;
+ }
+
+ // Building the WS Upgrade Header
+ byte[] digest = (wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(UTF_8);
+ String resp = "HTTP/1.1 101 Switching Protocols\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Sec-WebSocket-Accept: " +
+ Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1").digest(digest)) +
+ "\r\n\r\n";
+
+ // send headers + WS init message,
+ encodeBuf(resp);
+ while (buffer.hasRemaining()) sc.write(buffer);
+ while (initMsgBuffer.hasRemaining()) sc.write(initMsgBuffer);
+ // no need to clear buffer, since we continue;
+ initMsgBuffer.rewind();
+
+ /*
+ add SocketChannel to our concurrent queue, so we can
+ send reload message if necessary.
+ */
+ webSockets.offer(sc);
+ continue;
+
+ // it must otherwise be some type of resource.. lets find out
+ } else {
+ /*
+ could be index.html in another directory. For example, if
+ request is `/mypage/pageX`, we should serve `mypage/pageX/index.html`.
+ However, we need to test if the file actually exists.
+ */
+ res = root.resolve(req.substring(1)).resolve("index.html");
+
+ // If it doesn't exist, we must be requesting some resource like `/styles.css`
+ if (!Files.exists(res)) {
+ servingIndexHtml = false;
+ res = root.resolve(req.substring(1));
+
+ if (!Files.exists(res)) {
+ /*
+ Could be a source map resource. Currently j2cl is generating source maps
+ that work fine if you're at your root directory, like '/'. But sub-directories
+ don't work very well. If you're on page '/myapp/', the browser will
+ try to load the maps from '/myapp/sources/.../*.map', which does not exist.
+ So, we must strip the prefix before '/sources', and the file can resolve.
+
+ todo: look at adjusting Closure's --source_map_location_mapping to add a
+ secondary location mapping
+ https://github.com/google/closure-compiler/blob/bf351b9f099e55e2c6405d73b22aaee8924c6f87/src/com/google/javascript/jscomp/CommandLineRunner.java#L338-L341
+ */
+ int sourceIndex = req.indexOf("/sources/");
+ if (sourceIndex != -1) {
+ res = root.resolve(req.substring(sourceIndex + 1));
+ }
+ }
+ }
+ }
+
+ if (!Files.exists(res)) {
+ // might be SPA, so default to index.html if we can and not some 404 page.
+ if (Files.exists(indexHtmlPath)) {
+ res = indexHtmlPath;
+ servingIndexHtml = true;
+ } else {
+ System.err.println("No such file: " + res);
+ encodeBuf("HTTP/1.0 404 Not Found\r\n");
+ while (buffer.hasRemaining()) sc.write(buffer);
+ sc.close();
+ continue;
+ }
+ }
+
+ // build the response header
+ String date = Instant.now().atOffset(ZoneOffset.UTC).format(RFC_1123_DATE_TIME);
+ String respHeader = "HTTP/1.0 200 OK\r\n" +
+ "Content-Type: " + Files.probeContentType(res) + "\r\n" +
+ "Date: " + date + "\r\n";
+
+ /*
+ Since we're using BUNDLE_JAR for fast incremental recompilation,
+ we need to cache the large unoptimized bundles. Good news is that
+ these *.bundle.js files are 'revved' [1] with a hash, so we can simply
+ cache them forever.
+
+ We also cache the large j2cl-base.js... we should look at hashing this
+ resource in the future, although perhaps it will be invalidated
+ when bumping the j2cl-maven-plugin version.
+ [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#Revved_resources
+ */
+ String fileName = res.getFileName().toString();
+ if ("j2cl-base.js".equals(fileName) || fileName.endsWith("bundle.js")) {
+ respHeader += "Cache-Control: max-age=31536000\r\n";
+ }
+
+
+ long size = Files.size(res);
+
+ // if serving an index.html, insert the websocket JS
+ if (servingIndexHtml) {
+ // lets try to find the body tag
+ try (FileChannel fc = FileChannel.open(res)) {
+ // the index after the tag
+ long afterBodyTag = 0;
+ /*
+ The index.html might be bigger than the buffer, so we
+ load in iterations, taking care of the split case
+ ie, buffer1 = ......
+
+ If there's not at least "".length() chars, then we may exit
+ since we're looking for the position after this search string.
+ */
+ while (size - fc.position() > BODY_TAG_LEN) {
+ long lastPos = fc.position();
+
+ // read some of the file into the buffer
+ buffer.clear();
+ while (buffer.hasRemaining() && fc.position() < size) fc.read(buffer);
+ buffer.flip();
+
+ int bodyPosInBuffer = findInBuffer(BODY_TAG);
+ if (bodyPosInBuffer > 0) {
+ afterBodyTag = lastPos + bodyPosInBuffer;
+ break;
+ }
+
+ // be generous in case of split tag case.
+ fc.position(fc.position() - BODY_TAG_LEN);
+ }
+
+ // reset file position and begin to send to client
+ fc.position(0);
+
+ if (afterBodyTag == 0) {
+ // we could not find body tag.. just transfer the file as is
+ System.err.println("Could not find tag in " + res);
+ respHeader += "Content-Length: " + size + "\r\n\r\n";
+ // write header to socketchannel
+ encodeBuf(respHeader);
+ while (buffer.hasRemaining()) sc.write(buffer);
+ transferFile(fc, sc);
+
+ } else {
+ long contentLength = size + encodedJsBuf.limit();
+ respHeader += "Content-Length: " + contentLength + "\r\n\r\n";
+ // write header to socketchannel
+ encodeBuf(respHeader);
+ while (buffer.hasRemaining()) sc.write(buffer);
+
+ // send index.html up to afterBodyTag
+ long transferred = 0;
+ while (transferred < afterBodyTag)
+ transferred += fc.transferTo(transferred, afterBodyTag - transferred, sc);
+
+ // send the JS addition
+ while (encodedJsBuf.hasRemaining()) sc.write(encodedJsBuf);
+ encodedJsBuf.rewind();
+
+ // send remaining part of file
+ transferred = afterBodyTag;
+ while (transferred < size)
+ transferred += fc.transferTo(transferred, size - transferred, sc);
+ }
+ }
+
+ // if not servingIndexHtml, serve the resource
+ } else {
+ // add Content-Size to resp header, and send it
+ respHeader += "Content-Length: " + size + "\r\n\r\n";
+ encodeBuf(respHeader);
+ while (buffer.hasRemaining()) sc.write(buffer);
+
+ try (FileChannel fc = FileChannel.open(res)) {
+ transferFile(fc, sc);
+ }
+ }
+
+ sc.close();
+ }
+
+ } catch (IOException | NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ // todo; just following project convention here..
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Extracts a header value given the key (ex, "GET ") from
+ * start until end.
+ * Returns the String value on success,
+ * or null on failure. buffer is advanced.
+ */
+ private String getInBetween(String start, String end) throws CharacterCodingException {
+ int startI = findInBuffer(start);
+ if (startI < 0) return null;
+ int endI = findInBuffer(end) - end.length();
+ if (endI < 0) return null;
+
+ // Wish I had Java 13 ByteBuffer.slice
+ int lim = buffer.limit();
+ buffer.position(startI);
+ buffer.limit(endI);
+ String res = decodeBuf(buffer);
+ buffer.limit(lim);
+ buffer.position(endI);
+ return res;
+ }
+
+ /**
+ * searches for a given String in the UTF-8 buffer.
+ * Returns the advanced buffer position on success,
+ * or -1 on failure.
+ *
+ * todo: consider adding skips
+ */
+ private int findInBuffer(String s) {
+ byte[] search = s.getBytes(UTF_8);
+ int searchLimit = buffer.limit() - search.length;
+ search:
+ while (buffer.position() < searchLimit) {
+ for (byte b : search) if (buffer.get() != b) continue search;
+ return buffer.position();
+ }
+ return -1;
+ }
+
+ /**
+ * Encode the UTF-16 String to UTF-8 Buffer.
+ * Flips buffer when done, so position = 0.
+ */
+ private void encodeBuf(String s) {
+ buffer.clear();
+ utf8Encoder.reset();
+ utf8Encoder.encode(CharBuffer.wrap(s), buffer, true);
+ utf8Encoder.flush(buffer);
+ buffer.flip();
+ }
+
+ private String decodeBuf(ByteBuffer bb) throws CharacterCodingException {
+ String res = utf8Decoder.decode(bb).toString();
+ bb.flip();
+ return res;
+ }
+
+ /**
+ * transfers all data from FileChannel to SocketChannel, throwing
+ * exception on failure
+ */
+ private void transferFile(FileChannel fc, SocketChannel sc) throws IOException {
+ long transferred = 0;
+ long size = fc.size();
+ while (transferred < size)
+ transferred += fc.transferTo(transferred, size - transferred, sc);
+ }
+
+ /**
+ * Lifted from now lost SO answer. Encodes a websocket message.
+ */
+ private static byte[] encodeWSMsg(String mess) {
+ byte[] rawData = mess.getBytes();
+
+ int frameCount = 0;
+ byte[] frame = new byte[10];
+
+ frame[0] = (byte) 129;
+
+ if (rawData.length <= 125) {
+ frame[1] = (byte) rawData.length;
+ frameCount = 2;
+ } else if (rawData.length >= 126 && rawData.length <= 65535) {
+ frame[1] = (byte) 126;
+ int len = rawData.length;
+ frame[2] = (byte) ((len >> 8) & (byte) 255);
+ frame[3] = (byte) (len & (byte) 255);
+ frameCount = 4;
+ } else {
+ frame[1] = (byte) 127;
+ int len = rawData.length;
+ frame[2] = (byte) ((len >> 56) & (byte) 255);
+ frame[3] = (byte) ((len >> 48) & (byte) 255);
+ frame[4] = (byte) ((len >> 40) & (byte) 255);
+ frame[5] = (byte) ((len >> 32) & (byte) 255);
+ frame[6] = (byte) ((len >> 24) & (byte) 255);
+ frame[7] = (byte) ((len >> 16) & (byte) 255);
+ frame[8] = (byte) ((len >> 8) & (byte) 255);
+ frame[9] = (byte) (len & (byte) 255);
+ frameCount = 10;
+ }
+
+ int bLength = frameCount + rawData.length;
+
+ byte[] reply = new byte[bLength];
+
+ int bLim = 0;
+ for (int i = 0; i < frameCount; i++) {
+ reply[bLim] = frame[i];
+ bLim++;
+ }
+ for (int i = 0; i < rawData.length; i++) {
+ reply[bLim] = rawData[i];
+ bLim++;
+ }
+
+ return reply;
+ }
+}