From 35a75241b2a4eab52958c1713705435143a969d1 Mon Sep 17 00:00:00 2001 From: August Nagro 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"> 4.0.0 - dependency-replacements + + dependency-replacements + dependency-replacements + 1.0 + + app - 1.0 war @@ -14,6 +18,7 @@ 2.8.2 + @@ -38,21 +43,19 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.apache.maven.plugins + maven-war-plugin + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + google-snapshots 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"> 4.0.0 - dependency-replacements - gwt-entrypoint - 1.0 + + dependency-replacements + dependency-replacements + 1.0 + + gwt-entrypoint - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + + + org.apache.maven.plugins + maven-compiler-plugin + + + 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 @@ https://oss.sonatype.org/content/repositories/google-snapshots/ + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.1 + + + + 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ 1.0 + @@ -33,6 +34,16 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.apache.maven.plugins + maven-war-plugin + 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 @@ 1.0.2 + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + + + org.apache.maven.plugins + maven-compiler-plugin + + 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 @@ app + + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.1 + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + + + + + google-snapshots 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.1 + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.apache.maven.plugins + maven-war-plugin + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + 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 @@ - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + + + + org.apache.maven.plugins + maven-compiler-plugin + + 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 @@ 1.0.2 + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.6.1 - - 1.8 - 1.8 - - - - + + + org.apache.maven.plugins + maven-compiler-plugin + + 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 @@ app + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.1 + + + + + google-snapshots From 4805db3a4c355438ccda38562c218ebf9970c0da Mon Sep 17 00:00:00 2001 From: August Nagro 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 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 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 @@ com.vertispan.j2cl j2cl-maven-plugin - 0.16-SNAPSHOT + 0.17-SNAPSHOT maven-plugin J2CL Maven Plugin 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 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 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 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..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; + +/** + *

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:

+ * + *
    + *
  1. User requests index.html.
  2. + *
  3. We inject some JS into the page, and serve.
  4. + *
  5. The injected JS initiates a websocket connection with the server.
  6. + *
  7. We store this socket connection in a queue.
  8. + *
  9. When some source-file changes eventually trigger reload(), we poll() every + * connection and send a reload message.
  10. + *
  11. The injected JS receives this message and reloads the page. Loop to 1.
  12. + *
+ */ +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; + // 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:

+ *
    + *
  1. User saves their IDE, saving {@code module1/src/main/java/Main.java} and + * {@code module2/src/main/java/Util.java} at the same time.
  2. + *
  3. Thread A is running the WatchService for module1. The thread + * calls {@link #notifyBuilding()} and begins the (long) build.
  4. + *
  5. Thread B is running the WatchService for module2. The thread + * calls {@link #notifyBuilding()} and begins the (short) build.
  6. + *
  7. Thread B finishes and calls {@link #notifyBuildStepComplete()}.
  8. + *
  9. Thread A finishes and calls {@link #notifyBuildStepComplete()}
  10. + *
  11. All builds are now finished, triggering Phaser#onAdvance, which + * calls reload().
  12. + *
+ * + *

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 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 = ""; + 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 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; + } +} From ae73da22e696973663489b9d2d14c8ff49f7a621 Mon Sep 17 00:00:00 2001 From: augustnagro 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 '/'. + *

+ * 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 @@ -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; /** *

This class serves resources to the client and manages live-reloading.

@@ -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 } * @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 = "