From 35a75241b2a4eab52958c1713705435143a969d1 Mon Sep 17 00:00:00 2001
From: August Nagro <augustnagro@gmail.com>
Date: Sat, 2 Jan 2021 16:12:36 -0800
Subject: [PATCH 1/4] specify plugin versions correctly in IT tests

---
 src/it/dependency-replacements/app/pom.xml    | 33 ++++++++++---------
 .../gwt-entrypoint/pom.xml                    | 29 ++++++++--------
 src/it/dependency-replacements/pom.xml        | 22 +++++++++++++
 src/it/failing-htmlunit-test/pom.xml          | 24 +++++++-------
 src/it/hello-world-reactor/app/pom.xml        | 11 +++++++
 src/it/hello-world-reactor/lib/pom.xml        | 20 ++++-------
 src/it/hello-world-reactor/pom.xml            | 22 +++++++++++++
 src/it/hello-world-single/pom.xml             | 30 +++++++++--------
 src/it/issue-41/pom.xml                       | 24 +++++++-------
 src/it/java-assertions/pom.xml                | 24 +++++++-------
 src/it/simple-htmlunit-test/pom.xml           | 24 +++++++-------
 src/it/transitive-dependencies/app/pom.xml    | 24 +++++++-------
 src/it/transitive-dependencies/lib1/pom.xml   | 20 ++++-------
 src/it/transitive-dependencies/lib2/pom.xml   | 20 ++++-------
 src/it/transitive-dependencies/pom.xml        | 22 +++++++++++++
 15 files changed, 201 insertions(+), 148 deletions(-)

diff --git a/src/it/dependency-replacements/app/pom.xml b/src/it/dependency-replacements/app/pom.xml
index 601c4a56..3ca022f0 100644
--- a/src/it/dependency-replacements/app/pom.xml
+++ b/src/it/dependency-replacements/app/pom.xml
@@ -2,9 +2,13 @@
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
 
-  <groupId>dependency-replacements</groupId>
+  <parent>
+    <groupId>dependency-replacements</groupId>
+    <artifactId>dependency-replacements</artifactId>
+    <version>1.0</version>
+  </parent>
+
   <artifactId>app</artifactId>
-  <version>1.0</version>
   <packaging>war</packaging>
 
   <dependencies>
@@ -14,6 +18,7 @@
       <version>2.8.2</version>
     </dependency>
   </dependencies>
+
   <build>
     <plugins>
       <plugin>
@@ -38,21 +43,19 @@
           </execution>
         </executions>
       </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+      </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-war-plugin</artifactId>
+      </plugin>
     </plugins>
-    <pluginManagement>
-      <plugins>
-        <plugin>
-          <groupId>org.apache.maven.plugins</groupId>
-          <artifactId>maven-compiler-plugin</artifactId>
-          <version>3.6.1</version>
-          <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
-          </configuration>
-        </plugin>
-      </plugins>
-    </pluginManagement>
   </build>
+
   <repositories>
     <repository>
       <id>google-snapshots</id>
diff --git a/src/it/dependency-replacements/gwt-entrypoint/pom.xml b/src/it/dependency-replacements/gwt-entrypoint/pom.xml
index 525c0210..8f8f8dd4 100644
--- a/src/it/dependency-replacements/gwt-entrypoint/pom.xml
+++ b/src/it/dependency-replacements/gwt-entrypoint/pom.xml
@@ -2,25 +2,22 @@
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
 
-  <groupId>dependency-replacements</groupId>
-  <artifactId>gwt-entrypoint</artifactId>
-  <version>1.0</version>
+  <parent>
+    <groupId>dependency-replacements</groupId>
+    <artifactId>dependency-replacements</artifactId>
+    <version>1.0</version>
+  </parent>
 
+  <artifactId>gwt-entrypoint</artifactId>
 
   <build>
-    <pluginManagement>
-      <plugins>
-        <plugin>
-          <groupId>org.apache.maven.plugins</groupId>
-          <artifactId>maven-compiler-plugin</artifactId>
-          <version>3.6.1</version>
-          <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
-          </configuration>
-        </plugin>
-      </plugins>
-    </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+      </plugin>
+    </plugins>
+
   </build>
   <repositories>
     <repository>
diff --git a/src/it/dependency-replacements/pom.xml b/src/it/dependency-replacements/pom.xml
index 391c9c99..6634be33 100644
--- a/src/it/dependency-replacements/pom.xml
+++ b/src/it/dependency-replacements/pom.xml
@@ -18,4 +18,26 @@
       <url>https://oss.sonatype.org/content/repositories/google-snapshots/</url>
     </repository>
   </repositories>
+
+  <build>
+    <pluginManagement>
+      <plugins>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-compiler-plugin</artifactId>
+          <version>3.6.1</version>
+          <configuration>
+            <source>1.8</source>
+            <target>1.8</target>
+          </configuration>
+        </plugin>
+
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-war-plugin</artifactId>
+          <version>3.3.1</version>
+        </plugin>
+      </plugins>
+    </pluginManagement>
+  </build>
 </project>
diff --git a/src/it/failing-htmlunit-test/pom.xml b/src/it/failing-htmlunit-test/pom.xml
index 0c271ce3..4fc2dea2 100644
--- a/src/it/failing-htmlunit-test/pom.xml
+++ b/src/it/failing-htmlunit-test/pom.xml
@@ -64,20 +64,18 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.6.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
         </plugins>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+
     </build>
     <repositories>
         <repository>
diff --git a/src/it/hello-world-reactor/app/pom.xml b/src/it/hello-world-reactor/app/pom.xml
index c478a9ed..9454b916 100644
--- a/src/it/hello-world-reactor/app/pom.xml
+++ b/src/it/hello-world-reactor/app/pom.xml
@@ -17,6 +17,7 @@
             <version>1.0</version>
         </dependency>
     </dependencies>
+
     <build>
         <plugins>
             <plugin>
@@ -33,6 +34,16 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+            </plugin>
         </plugins>
     </build>
 </project>
diff --git a/src/it/hello-world-reactor/lib/pom.xml b/src/it/hello-world-reactor/lib/pom.xml
index e7961126..9a500aa8 100644
--- a/src/it/hello-world-reactor/lib/pom.xml
+++ b/src/it/hello-world-reactor/lib/pom.xml
@@ -17,19 +17,13 @@
             <version>1.0.2</version>
         </dependency>
     </dependencies>
+
     <build>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+            </plugin>
+        </plugins>
     </build>
 </project>
diff --git a/src/it/hello-world-reactor/pom.xml b/src/it/hello-world-reactor/pom.xml
index 6bf64517..404ffeb7 100644
--- a/src/it/hello-world-reactor/pom.xml
+++ b/src/it/hello-world-reactor/pom.xml
@@ -12,6 +12,28 @@
         <module>app</module>
     </modules>
 
+    <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-war-plugin</artifactId>
+                    <version>3.3.1</version>
+                </plugin>
+
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-compiler-plugin</artifactId>
+                    <version>3.6.1</version>
+                    <configuration>
+                        <source>1.8</source>
+                        <target>1.8</target>
+                    </configuration>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+    </build>
+
     <repositories>
         <repository>
             <id>google-snapshots</id>
diff --git a/src/it/hello-world-single/pom.xml b/src/it/hello-world-single/pom.xml
index 107f2a55..bb9ee4e6 100644
--- a/src/it/hello-world-single/pom.xml
+++ b/src/it/hello-world-single/pom.xml
@@ -30,20 +30,24 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.6.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.3.1</version>
+            </plugin>
         </plugins>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+
     </build>
     <repositories>
         <repository>
diff --git a/src/it/issue-41/pom.xml b/src/it/issue-41/pom.xml
index 9188fcec..64e5f508 100644
--- a/src/it/issue-41/pom.xml
+++ b/src/it/issue-41/pom.xml
@@ -53,20 +53,18 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.6.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
         </plugins>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+
     </build>
     <repositories>
         <repository>
diff --git a/src/it/java-assertions/pom.xml b/src/it/java-assertions/pom.xml
index 560d75fd..7c45a64a 100644
--- a/src/it/java-assertions/pom.xml
+++ b/src/it/java-assertions/pom.xml
@@ -98,20 +98,18 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.6.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
         </plugins>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+
     </build>
     <repositories>
         <repository>
diff --git a/src/it/simple-htmlunit-test/pom.xml b/src/it/simple-htmlunit-test/pom.xml
index 64e15f23..c8adf723 100644
--- a/src/it/simple-htmlunit-test/pom.xml
+++ b/src/it/simple-htmlunit-test/pom.xml
@@ -54,20 +54,18 @@
                     </execution>
                 </executions>
             </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.6.1</version>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
         </plugins>
-        <pluginManagement>
-            <plugins>
-                <plugin>
-                    <groupId>org.apache.maven.plugins</groupId>
-                    <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.6.1</version>
-                    <configuration>
-                        <source>1.8</source>
-                        <target>1.8</target>
-                    </configuration>
-                </plugin>
-            </plugins>
-        </pluginManagement>
+
     </build>
     <repositories>
         <repository>
diff --git a/src/it/transitive-dependencies/app/pom.xml b/src/it/transitive-dependencies/app/pom.xml
index b0e94caa..4654e80e 100644
--- a/src/it/transitive-dependencies/app/pom.xml
+++ b/src/it/transitive-dependencies/app/pom.xml
@@ -33,19 +33,17 @@
           </execution>
         </executions>
       </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+      </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-war-plugin</artifactId>
+      </plugin>
     </plugins>
-    <pluginManagement>
-      <plugins>
-        <plugin>
-          <groupId>org.apache.maven.plugins</groupId>
-          <artifactId>maven-compiler-plugin</artifactId>
-          <version>3.6.1</version>
-          <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
-          </configuration>
-        </plugin>
-      </plugins>
-    </pluginManagement>
+
   </build>
 </project>
diff --git a/src/it/transitive-dependencies/lib1/pom.xml b/src/it/transitive-dependencies/lib1/pom.xml
index b9465e90..c014cede 100644
--- a/src/it/transitive-dependencies/lib1/pom.xml
+++ b/src/it/transitive-dependencies/lib1/pom.xml
@@ -23,18 +23,12 @@
     </dependency>
   </dependencies>
   <build>
-    <pluginManagement>
-      <plugins>
-        <plugin>
-          <groupId>org.apache.maven.plugins</groupId>
-          <artifactId>maven-compiler-plugin</artifactId>
-          <version>3.6.1</version>
-          <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
-          </configuration>
-        </plugin>
-      </plugins>
-    </pluginManagement>
+
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+      </plugin>
+    </plugins>
   </build>
 </project>
diff --git a/src/it/transitive-dependencies/lib2/pom.xml b/src/it/transitive-dependencies/lib2/pom.xml
index bfb3f3ef..a489cb7e 100644
--- a/src/it/transitive-dependencies/lib2/pom.xml
+++ b/src/it/transitive-dependencies/lib2/pom.xml
@@ -17,19 +17,13 @@
       <version>1.0.2</version>
     </dependency>
   </dependencies>
+
   <build>
-    <pluginManagement>
-      <plugins>
-        <plugin>
-          <groupId>org.apache.maven.plugins</groupId>
-          <artifactId>maven-compiler-plugin</artifactId>
-          <version>3.6.1</version>
-          <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
-          </configuration>
-        </plugin>
-      </plugins>
-    </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+      </plugin>
+    </plugins>
   </build>
 </project>
diff --git a/src/it/transitive-dependencies/pom.xml b/src/it/transitive-dependencies/pom.xml
index f4b6292d..ebfefe8a 100644
--- a/src/it/transitive-dependencies/pom.xml
+++ b/src/it/transitive-dependencies/pom.xml
@@ -14,6 +14,28 @@
     <module>app</module>
   </modules>
 
+  <build>
+    <pluginManagement>
+      <plugins>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-compiler-plugin</artifactId>
+          <version>3.6.1</version>
+          <configuration>
+            <source>1.8</source>
+            <target>1.8</target>
+          </configuration>
+        </plugin>
+
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-war-plugin</artifactId>
+          <version>3.3.1</version>
+        </plugin>
+      </plugins>
+    </pluginManagement>
+  </build>
+
   <repositories>
     <repository>
       <id>google-snapshots</id>

From 4805db3a4c355438ccda38562c218ebf9970c0da Mon Sep 17 00:00:00 2001
From: August Nagro <augustnagro@gmail.com>
Date: Sat, 2 Jan 2021 16:13:29 -0800
Subject: [PATCH 2/4] Default j2cl:build initialScriptFilename =
 ${artifactId}.js

This change harmonizes j2cl:build and j2cl:watch outputs.

Now that :watch copies resources like index.html to ${webappsDirectory}/${artifactId}/,
it's not clear which <script> src to use for the output js
(:watch needs src="/${artifactId}.js" to resolve, and :build
needs src="/${artifactId}/${artifactId}.js").

By setting :build's initialScriptFilename = ${artifactId}.js by default,
index.html can simply use <script src="/${artifactId}.js"></script> without
confusion.
---
 src/main/java/net/cardosi/mojo/BuildMojo.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/net/cardosi/mojo/BuildMojo.java b/src/main/java/net/cardosi/mojo/BuildMojo.java
index 5215dd58..f2e8f636 100644
--- a/src/main/java/net/cardosi/mojo/BuildMojo.java
+++ b/src/main/java/net/cardosi/mojo/BuildMojo.java
@@ -101,7 +101,7 @@ public class BuildMojo extends AbstractBuildMojo implements ClosureBuildConfigur
     @Parameter(defaultValue = Artifact.SCOPE_RUNTIME, required = true)
     protected String classpathScope;
 
-    @Parameter(defaultValue = "${project.artifactId}/${project.artifactId}.js", required = true)
+    @Parameter(defaultValue = "${project.artifactId}.js", required = true)
     protected String initialScriptFilename;
 
     @Parameter(defaultValue = "${project.build.directory}/${project.build.finalName}", required = true)

From 4035c597c243f4726a7013e611156734bc1bec69 Mon Sep 17 00:00:00 2001
From: August Nagro <augustnagro@gmail.com>
Date: Sat, 2 Jan 2021 16:28:42 -0800
Subject: [PATCH 3/4] hot-reload feature

---
 pom.xml                                       |   2 +-
 .../net/cardosi/mojo/AbstractBuildMojo.java   |  12 +-
 src/main/java/net/cardosi/mojo/TestMojo.java  |   4 +-
 src/main/java/net/cardosi/mojo/WatchMojo.java |  77 ++-
 .../net/cardosi/mojo/cache/CachedProject.java | 233 ++++++--
 .../net/cardosi/mojo/tools/DevServer.java     | 531 ++++++++++++++++++
 6 files changed, 807 insertions(+), 52 deletions(-)
 create mode 100644 src/main/java/net/cardosi/mojo/tools/DevServer.java

diff --git a/pom.xml b/pom.xml
index ec96f896..ae50f0b3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
 
   <groupId>com.vertispan.j2cl</groupId>
   <artifactId>j2cl-maven-plugin</artifactId>
-  <version>0.16-SNAPSHOT</version>
+  <version>0.17-SNAPSHOT</version>
   <packaging>maven-plugin</packaging>
 
   <name>J2CL Maven Plugin</name>
diff --git a/src/main/java/net/cardosi/mojo/AbstractBuildMojo.java b/src/main/java/net/cardosi/mojo/AbstractBuildMojo.java
index 3e9456dc..b805a1bf 100644
--- a/src/main/java/net/cardosi/mojo/AbstractBuildMojo.java
+++ b/src/main/java/net/cardosi/mojo/AbstractBuildMojo.java
@@ -24,6 +24,8 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -215,9 +217,15 @@ protected CachedProject loadDependenciesIntoCache(
             p = seen.get(key);
             p.replace(artifact, currentProject, children);
         } else {
-            p = new CachedProject(diskCache, artifact, currentProject, children);
-            seen.put(key, p);
+            Path webappPath = null;
+            File basedir = currentProject.getBasedir();
+            if (basedir != null) {
+                webappPath = basedir.toPath().resolve("src/main/webapp");
+                if (!Files.exists(webappPath)) webappPath = null;
+            }
 
+            p = new CachedProject(diskCache, artifact, currentProject, children, webappPath);
+            seen.put(key, p);
             p.markDirty();
         }
 
diff --git a/src/main/java/net/cardosi/mojo/TestMojo.java b/src/main/java/net/cardosi/mojo/TestMojo.java
index 5f0ef5ee..3b1e8782 100644
--- a/src/main/java/net/cardosi/mojo/TestMojo.java
+++ b/src/main/java/net/cardosi/mojo/TestMojo.java
@@ -196,7 +196,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
             // only this should have the scope=test deps on it
             List<CachedProject> children = new ArrayList<>(source.getChildren());
             children.add(source);
-            CachedProject e = new CachedProject(diskCache, project.getArtifact(), project, children, project.getTestCompileSourceRoots(), project.getTestResources());
+            CachedProject e = new CachedProject(diskCache, project.getArtifact(), project, children, project.getTestCompileSourceRoots(), project.getTestResources(), null);
 
             diskCache.release();
 
@@ -233,7 +233,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
                 // Synthesize a new project which only depends on the last one, and only contains the named test's .testsuite content, remade into a one-off JS file
                 ArrayList<CachedProject> 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..3787a078 100644
--- a/src/main/java/net/cardosi/mojo/WatchMojo.java
+++ b/src/main/java/net/cardosi/mojo/WatchMojo.java
@@ -4,6 +4,7 @@
 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;
@@ -21,9 +22,11 @@
 
 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 java.util.concurrent.TimeUnit;
 
 /**
  * Attempts to do the setup for various test and build goals declared in the current project or in child projects,
@@ -91,8 +94,26 @@ 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 '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 +201,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 +225,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, 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<CachedProject> dependents = new ArrayList<>();
     private final List<String> compileSourceRoots;
     private final List<Resource> resources;
+    private final Path webappPath;
 
     private final Map<String, CompletableFuture<TranspiledCacheEntry>> steps = new ConcurrentHashMap<>();
 
     private Set<Supplier<CompletableFuture<TranspiledCacheEntry>>> registeredBuildTerminals = new HashSet<>();
 
-    public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List<CachedProject> children, List<String> compileSourceRoots, List<Resource> resources) {
+    public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List<CachedProject> children, List<String> compileSourceRoots, List<Resource> 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<CachedProject> children) {
-        this(diskCache, artifact, currentProject, children, currentProject.getCompileSourceRoots(), currentProject.getResources());
+    public CachedProject(DiskCache diskCache, Artifact artifact, MavenProject currentProject, List<CachedProject> children, Path webappPath) {
+        this(diskCache, artifact, currentProject, children, currentProject.getCompileSourceRoots(), currentProject.getResources(), webappPath);
     }
 
     public void replace(Artifact artifact, MavenProject currentProject, List<CachedProject> children) {
@@ -97,11 +101,8 @@ public void replace(Artifact artifact, MavenProject currentProject, List<CachedP
      *
      * TODO this could be updated to compare before/after hash and avoid marking children as dirty
      */
-    public void markDirty() {
+    public CompletableFuture<Void> markDirty() {
         synchronized (steps) {
-            if (steps.isEmpty()) {
-                return;
-            }
             // cancel all running work
             for (CompletableFuture<TranspiledCacheEntry> 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<FileSystem, List<Path>> 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<WatchEvent<?>> 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<FileSystem, List<Path>> fileSystemsToWatch = compileSourceRoots.stream()
+            .map(Paths::get)
+            .collect(Collectors.groupingBy(Path::getFileSystem));
+
         for (Map.Entry<FileSystem, List<Path>> entry : fileSystemsToWatch.entrySet()) {
             WatchService watchService = entry.getKey().newWatchService();
-            for (Path path : entry.getValue()) {
-                Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
-                    @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<WatchEvent<?>> 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<Path>() {
+            @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<TranspiledCacheEntry> registerAsApp(ClosureBuildConfiguration config) {
         Supplier<CompletableFuture<TranspiledCacheEntry>> supplier = () -> jscompWithScope(config);
         registeredBuildTerminals.add(supplier);
@@ -924,6 +1080,9 @@ private CompletableFuture<TranspiledCacheEntry> 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..fa06e56c
--- /dev/null
+++ b/src/main/java/net/cardosi/mojo/tools/DevServer.java
@@ -0,0 +1,531 @@
+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 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 java.util.Base64;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Phaser;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
+
+/**
+ * <p>This class serves resources to the client and manages live-reloading.</p>
+ * <p>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:</p>
+ *
+ * <ol>
+ *   <li>User requests index.html.</li>
+ *   <li>We inject some JS into the page, and serve.</li>
+ *   <li>The injected JS initiates a websocket connection with the server.</li>
+ *   <li>We store this socket connection in a queue.</li>
+ *   <li>When some source-file changes eventually trigger reload(), we poll() every
+ *   connection and send a reload message.</li>
+ *   <li>The injected JS receives this message and reloads the page. Loop to 1.</li>
+ * </ol>
+ */
+public class DevServer implements Runnable {
+
+    // we inject some javascript after this tag in index.html
+    private static final String BODY_TAG = "<body>";
+    private static final int BODY_TAG_LEN = BODY_TAG.length();
+
+    // webserver root
+    private final Path root;
+    // 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<SocketChannel> webSockets = new ConcurrentLinkedQueue<>();
+    /**
+     * <p>This Phaser determines when to reload() the tabs. Using Phaser solves the problem
+     * of reloading before all build steps have finished. Consider:</p>
+     * <ol>
+     *   <li>User saves their IDE, saving {@code module1/src/main/java/Main.java} and
+     *   {@code module2/src/main/java/Util.java} at the same time.</li>
+     *   <li>Thread A is running the WatchService for module1. The thread
+     *   calls {@link #notifyBuilding()} and begins the (long) build.</li>
+     *   <li>Thread B is running the WatchService for module2. The thread
+     *   calls {@link #notifyBuilding()} and begins the (short) build.</li>
+     *   <li>Thread B finishes and calls {@link #notifyBuildStepComplete()}.</li>
+     *   <li>Thread A finishes and calls {@link #notifyBuildStepComplete()}</li>
+     *   <li>All builds are now finished, triggering Phaser#onAdvance, which
+     *   calls reload().</li>
+     * </ol>
+     *
+     * <p>Note: This is a classic CyclicBarrier problem. Phaser is perfect because, unlike
+     * CyclicBarrier, the number of registered parties can be dynamic.</p>
+     */
+    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 port Port to bind on localhost
+     */
+    public DevServer(Path root, int port) {
+        this.root = root;
+        indexHtmlPath = root.resolve("index.html");
+        this.port = port;
+
+        String js = "<script>" +
+                    "(function() {" +
+                    "var websocket=new WebSocket('ws://localhost:" + port + "/_serveWebsocket');" +
+                    "websocket.onmessage=function(e){" +
+                    "if (e.data!=='init')location.reload();" +
+                    "};" +
+                    "})();" +
+                    "</script>";
+        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));
+            }
+
+            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;
+                }
+
+                /*
+                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 <body> 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 = ...<bo
+                        and buffer2 = dy>...
+
+                        If there's not at least "<body>".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 <body> 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 <body> 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.
+     * <p>
+     * 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;
+    }
+}

From ae73da22e696973663489b9d2d14c8ff49f7a621 Mon Sep 17 00:00:00 2001
From: augustnagro <augustnagro@gmail.com>
Date: Sat, 23 Jan 2021 15:27:03 -0800
Subject: [PATCH 4/4] add base href support in WatchMojo

---
 src/main/java/net/cardosi/mojo/WatchMojo.java | 30 ++++++++++-----
 .../net/cardosi/mojo/tools/DevServer.java     | 37 ++++++++++++++++---
 2 files changed, 53 insertions(+), 14 deletions(-)

diff --git a/src/main/java/net/cardosi/mojo/WatchMojo.java b/src/main/java/net/cardosi/mojo/WatchMojo.java
index 3787a078..d77f733b 100644
--- a/src/main/java/net/cardosi/mojo/WatchMojo.java
+++ b/src/main/java/net/cardosi/mojo/WatchMojo.java
@@ -1,6 +1,13 @@
 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;
@@ -20,14 +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.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.*;
-import java.util.concurrent.CompletableFuture;
-
 /**
  * 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
@@ -105,6 +104,19 @@ public class WatchMojo extends AbstractBuildMojo {
      */
     @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 '/'.
+     * <p>
+     * Note that using the {@code <base>} 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
@@ -248,7 +260,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
                 }
             }
 
-            devServer = new DevServer(devServerRoot, devServerPort);
+            devServer = new DevServer(devServerRoot, devServerBaseHref, devServerPort);
 
             // initial build
             devServer.notifyBuilding();
diff --git a/src/main/java/net/cardosi/mojo/tools/DevServer.java b/src/main/java/net/cardosi/mojo/tools/DevServer.java
index fa06e56c..e8f5d782 100644
--- a/src/main/java/net/cardosi/mojo/tools/DevServer.java
+++ b/src/main/java/net/cardosi/mojo/tools/DevServer.java
@@ -12,18 +12,19 @@
 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 static java.nio.charset.StandardCharsets.UTF_8;
-import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * <p>This class serves resources to the client and manages live-reloading.</p>
@@ -48,6 +49,9 @@ public class DevServer implements Runnable {
 
     // 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
@@ -94,11 +98,26 @@ protected boolean onAdvance(int phase, int registeredParties) {
 
     /**
      * @param root Path to host files from
+     * @param baseHref Equivalent to {@code <base href="">}
      * @param port Port to bind on localhost
      */
-    public DevServer(Path root, int port) {
+    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 = "<script>" +
@@ -167,7 +186,7 @@ public void run() {
             if (Files.exists(indexHtmlPath) &&
                 Desktop.isDesktopSupported() &&
                 (desktop = Desktop.getDesktop()).isSupported(Desktop.Action.BROWSE)) {
-                desktop.browse(URI.create("http://localhost:" + port));
+                desktop.browse(URI.create("http://localhost:" + port + baseHref));
             }
 
             while (true) {
@@ -198,6 +217,14 @@ public void run() {
                     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