From 9cd821fc9b8044cf9274d92a075cac260e4c600a Mon Sep 17 00:00:00 2001 From: Colin Alworth Date: Thu, 24 Feb 2022 13:37:06 -0600 Subject: [PATCH] Handle static resources and externs (#117) This establishes a simple set of conventions for static resources and externs. While encouraged, this new convention for externs is not enforced or warned yet. See discussion #116 Fixes #39 --- Readme.md | 29 ++-- .../com/vertispan/j2cl/build/task/Input.java | 3 + .../src/it/static-resources/pom.xml | 87 ++++++++++++ .../src/main/java/com/example/App.java | 29 ++++ .../src/main/java/com/example/App.native.js | 3 + .../main/java/com/example/public/index.html | 9 ++ .../java/com/example/public/publicfile.js | 2 + .../main/resources/META-INF/ignoredfile.js | 1 + .../resources/metainfresourcesfile.js | 2 + .../src/test/java/com/example/AppTest.java | 34 +++++ .../j2cl/build/provided/BundleJarTask.java | 20 ++- .../j2cl/build/provided/BytecodeTask.java | 5 +- .../j2cl/build/provided/ClosureTask.java | 132 +++++++++++++++--- .../j2cl/build/provided/IJarTask.java | 6 +- .../j2cl/build/provided/JsZipBundleTask.java | 5 +- .../build/provided/TestCollectionTask.java | 5 +- 16 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 j2cl-maven-plugin/src/it/static-resources/pom.xml create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.java create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.native.js create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/index.html create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/publicfile.js create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/ignoredfile.js create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/resources/metainfresourcesfile.js create mode 100644 j2cl-maven-plugin/src/it/static-resources/src/test/java/com/example/AppTest.java diff --git a/Readme.md b/Readme.md index e3ac2233..1debf8cd 100644 --- a/Readme.md +++ b/Readme.md @@ -1,15 +1,19 @@ J2CL Maven plugin ================= -This plugin includes the code original developed as +This plugins compiles Java sources to optimized JavaScript using https://github.com/google/j2cl/ and +https://github.com/google/closure-compiler/. All Java code in this project will be transpiled to JS, +and any source in dependencies will be transpiled as well, and all of that JS will then be optimized +with the closure-compiler to produce small, efficient JavaScript. - com.vertispan.j2cl:build-tools +Webjars that are included in the project's list of runtime dependencies will be made available in the +compile output, placed relative to the initial script's output directory. -built from here: +Resources present in a `public/` directory within normal Java packages will also be copied to the +output directory. - https://github.com/gitgabrio/j2cl-devmode-strawman - ------------------------- +All other JS found in Java packages will be assumed to be JavaScript that should be included in the +main build output, and is assumed to be safe to compile with closure. # Example usage @@ -23,20 +27,23 @@ tests](j2cl-maven-plugin/src/it/) used to verify various aspects of the project The plugin has four goals -1. `build`: executes a single compilation, typically to produce a JS application or library. +1. `build`: executes a single compilation, typically to produce a JS application or library. Bound by +default to the `prepare-package` phase. -2. `test`: compiles and executes j2cl-annotated tests, once. +2. `test`: compiles and executes j2cl-annotated tests. Bound by default to the `test` phase. 3. `watch`: monitor source directories, and when changes happen that affect any `build` or `test`, recompile the -required parts of the project. Tests are not (presently) run, but can be run manually by loading the html pages -that exist for them. +required parts of the project. While this can be run on an individual client project, it is designed to run +on an entire reactor at once from the parent project, where it will notice changes from any project required by +the actual client projects, and can be directed to generate output anywhere that the server will notice and +serve it. -4. `clean`: cleans up all the plugin-specific directories +4. `clean`: cleans up all the plugin-specific directories. ---- diff --git a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java index bbbbf323..8d8db900 100644 --- a/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java +++ b/build-caching/src/main/java/com/vertispan/j2cl/build/task/Input.java @@ -10,6 +10,9 @@ public interface Input { * * Specifies that only part of this input is required. A path entry that matches any * of the provided filters will be included. + * + * The path parameter given to the matcher will be the relative path of the file within + * this input - the parent path will not be provided. */ Input filter(PathMatcher... filters); diff --git a/j2cl-maven-plugin/src/it/static-resources/pom.xml b/j2cl-maven-plugin/src/it/static-resources/pom.xml new file mode 100644 index 00000000..9b4cdac7 --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/pom.xml @@ -0,0 +1,87 @@ + + 4.0.0 + + static-resources + static-resources + 1.0 + + + 1.1.0 + + + + + com.google.elemental2 + elemental2-core + ${elemental2.version} + + + com.google.elemental2 + elemental2-dom + ${elemental2.version} + + + com.google.elemental2 + elemental2-promise + ${elemental2.version} + + + + com.vertispan.j2cl + junit-annotations + @j2cl.version@ + test + + + com.vertispan.j2cl + junit-emul + @j2cl.version@ + test + + + com.vertispan.j2cl + junit-emul + @j2cl.version@ + sources + test + + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build + + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + 1.8 + 1.8 + + + + + + + + google-snapshots + https://oss.sonatype.org/content/repositories/google-snapshots/ + + + diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.java b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.java new file mode 100644 index 00000000..23d6d589 --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.java @@ -0,0 +1,29 @@ +package com.example; + +import elemental2.dom.DomGlobal; +import elemental2.dom.Response; +import elemental2.promise.Promise; +import jsinterop.base.Js; +import jsinterop.annotations.JsType; + +@JsType +public class App { + public static void main() { + getData("publicfile.js").then(data -> alert(data), err -> alert("fail!")); + getData("metainfresourcesfile.js").then(data -> alert(data), err -> alert("fail!")); + getData("ignoredfile.js").then(data -> alert("fail!"), err -> alert("success, file missing")); + } + private static Promise alert(Object data) { + DomGlobal.alert(data); + return Promise.resolve(data); + } + public static Promise getData(String path) { + return DomGlobal.fetch(path) + .then(response -> { + if (response.ok) { + return response.text(); + } + return Promise.reject(response.status + ""); + }); + } +} diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.native.js b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.native.js new file mode 100644 index 00000000..bf37ff64 --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/App.native.js @@ -0,0 +1,3 @@ +setTimeout(function() { + App.main(); +}, 0); diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/index.html b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/index.html new file mode 100644 index 00000000..fbb97113 --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/index.html @@ -0,0 +1,9 @@ + + + + + + static resources test + +this is stored in the public/ directory, so it is in the same dir as the output js in the webappDirectory + diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/publicfile.js b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/publicfile.js new file mode 100644 index 00000000..3769eda7 --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/java/com/example/public/publicfile.js @@ -0,0 +1,2 @@ +com/example/public +" this file fails to parse, but that's okay, it will not be loaded as js diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/ignoredfile.js b/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/ignoredfile.js new file mode 100644 index 00000000..804f580b --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/ignoredfile.js @@ -0,0 +1 @@ +" Deliberate parse error, if this file is seen by closure it will fail diff --git a/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/resources/metainfresourcesfile.js b/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/resources/metainfresourcesfile.js new file mode 100644 index 00000000..849d1bca --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/main/resources/META-INF/resources/metainfresourcesfile.js @@ -0,0 +1,2 @@ +META-INF/resources +" this file fails to parse, but that's okay, it will not be loaded as js diff --git a/j2cl-maven-plugin/src/it/static-resources/src/test/java/com/example/AppTest.java b/j2cl-maven-plugin/src/it/static-resources/src/test/java/com/example/AppTest.java new file mode 100644 index 00000000..5b6843df --- /dev/null +++ b/j2cl-maven-plugin/src/it/static-resources/src/test/java/com/example/AppTest.java @@ -0,0 +1,34 @@ +package com.example; + +import com.google.j2cl.junit.apt.J2clTestInput; + +import elemental2.promise.Promise; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Test has to be run by hand, htmlunit doesn't support fetch: + * https://github.com/HtmlUnit/htmlunit/issues/78 + */ +@J2clTestInput(AppTest.class) +public class AppTest { + @Test(timeout = 1000) + public Promise testPublicFile() { + return App.getData("publicfile.js"); + } + @Test(timeout = 1000) + public Promise testMetaInfFile() { + return App.getData("metainfresourcesfile.js"); + } + @Test(timeout = 1000) + public Promise testIgnoredFile() { + // failure expected, we handle that in the .then() so that the test will only see success + return App.getData("ignoredfile.js").then(text -> Promise.reject("failure expected"), fail -> { + if ("404".equals(fail)) { + return Promise.resolve("Succcess, saw 404"); + } + return Promise.reject("Expected 404, saw " + fail); + }); + } +} diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BundleJarTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BundleJarTask.java index 76ad9d25..d5f1203d 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BundleJarTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BundleJarTask.java @@ -17,6 +17,10 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static com.vertispan.j2cl.build.provided.ClosureTask.COPIED_OUTPUT; +import static com.vertispan.j2cl.build.provided.ClosureTask.copiedOutputPath; +import static com.vertispan.j2cl.build.provided.JsZipBundleTask.JSZIP_BUNDLE_OUTPUT_TYPE; + @AutoService(TaskFactory.class) public class BundleJarTask extends TaskFactory { @@ -67,11 +71,20 @@ public Task resolve(Project project, Config config) { } //cheaty, but lets us cache - Input jszip = input(project, "jszipbundle"); + Input jszip = input(project, JSZIP_BUNDLE_OUTPUT_TYPE); File initialScriptFile = config.getWebappDirectory().resolve(config.getInitialScriptFilename()).toFile(); Map defines = new LinkedHashMap<>(config.getDefines()); + List outputToCopy = Stream.concat( + Stream.of(project), + scope(project.getDependencies(), Dependency.Scope.RUNTIME).stream() + ) + // Only need to consider the original inputs and generated sources, + // J2CL won't contribute this kind of sources + .map(p -> input(p, OutputTypes.BYTECODE).filter(COPIED_OUTPUT)) + .collect(Collectors.toList()); + return new FinalOutputTask() { @Override public void execute(TaskContext context) throws Exception { @@ -124,6 +137,11 @@ public void finish(TaskContext taskContext) throws IOException { } catch (IOException e) { throw new UncheckedIOException("Failed to write html import file", e); } + for (Input input : outputToCopy) { + for (CachedPath entry : input.getFilesAndHashes()) { + copiedOutputPath(initialScriptFile.getParentFile().toPath(), entry); + } + } } }; } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BytecodeTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BytecodeTask.java index 4bc9ea10..24a6a2e4 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BytecodeTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/BytecodeTask.java @@ -51,8 +51,9 @@ public Task resolve(Project project, Config config) { Input existingUnpackedBytecode = input(project, OutputTypes.INPUT_SOURCES); return context -> { for (CachedPath entry : existingUnpackedBytecode.getFilesAndHashes()) { - Files.createDirectories(context.outputPath().resolve(entry.getSourcePath()).getParent()); - Files.copy(entry.getAbsolutePath(), context.outputPath().resolve(entry.getSourcePath())); + Path outputFile = context.outputPath().resolve(entry.getSourcePath()); + Files.createDirectories(outputFile.getParent()); + Files.copy(entry.getAbsolutePath(), outputFile); } }; } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java index 4a41c3d8..f57cf2cc 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/ClosureTask.java @@ -14,15 +14,56 @@ import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; @AutoService(TaskFactory.class) public class ClosureTask extends TaskFactory { - public static final PathMatcher JS_SOURCES = withSuffix(".js"); - public static final PathMatcher NATIVE_JS_SOURCES = withSuffix(".native.js"); + private static final Path META_INF = Paths.get("META-INF"); + /** servlet 3 and webjars convention */ + private static final Path META_INF_RESOURCES = META_INF.resolve("resources"); + /** optional directory to offer externs within a jar */ + private static final Path META_INF_EXTERNS = META_INF.resolve("externs"); + + private static final Path PUBLIC = Paths.get("public"); + + private static final PathMatcher JS_SOURCES = withSuffix(".js"); + private static final PathMatcher NATIVE_JS_SOURCES = withSuffix(".native.js"); + private static final PathMatcher EXTERNS_SOURCES = withSuffix(".externs.js"); + + private static final PathMatcher IN_META_INF = path -> path.startsWith(META_INF); + private static final PathMatcher IN_META_INF_EXTERNS = path -> path.startsWith(META_INF_EXTERNS); + private static final PathMatcher IN_META_INF_RESOURCES = path -> path.startsWith(META_INF_RESOURCES); + + private static final PathMatcher IN_PUBLIC = path -> StreamSupport.stream(path.spliterator(), false).anyMatch(PUBLIC::equals); + + /** + * JS files that closure should use as type information + */ + public static final PathMatcher EXTERNS = new PathMatcher() { + @Override + public boolean matches(Path path) { + return IN_META_INF_EXTERNS.matches(path) || EXTERNS_SOURCES.matches(path); + } + + @Override + public String toString() { + return "externs to pass to closure"; + } + }; + + /** + * JS files that closure should accept as input to bundle/compile. + */ public static final PathMatcher PLAIN_JS_SOURCES = new PathMatcher() { @Override public boolean matches(Path path) { - return JS_SOURCES.matches(path) && !NATIVE_JS_SOURCES.matches(path); + if (IN_META_INF.matches(path) && !IN_META_INF_EXTERNS.matches(path)) { + return false; + } + if (IN_PUBLIC.matches(path)) { + return false; + } + return JS_SOURCES.matches(path) && !NATIVE_JS_SOURCES.matches(path) && !EXTERNS.matches(path); } @Override @@ -30,6 +71,50 @@ public String toString() { return "Only non-native JS sources"; } }; + + /** + * Files that should be copied to the final output directory. + */ + public static final PathMatcher COPIED_OUTPUT = new PathMatcher() { + @Override + public boolean matches(Path path) { + return IN_PUBLIC.matches(path) || IN_META_INF_RESOURCES.matches(path); + } + + @Override + public String toString() { + return "Output to copy without transpiling or bundling"; + } + }; + + /** Strips off any prefix and returns an absolute path describing where to copy the file */ + public static void copiedOutputPath(Path outputDirectory, CachedPath fileToCopy) throws IOException { + Path sourcePath = fileToCopy.getSourcePath(); + final Path outputPath; + if (IN_META_INF_RESOURCES.matches(sourcePath)) { + outputPath = META_INF_RESOURCES.relativize(sourcePath); + } else if (IN_PUBLIC.matches(sourcePath)) { + List dir = new ArrayList<>(); + boolean seenPublic = false; + for (Path path : sourcePath) { + if (!seenPublic) { + if (path.equals(PUBLIC)) { + seenPublic = true; + } + continue; + } + dir.add(path.toString()); + } + + outputPath = Paths.get(dir.remove(0), dir.toArray(new String[0])); + } else { + throw new IllegalStateException("Output file not in public/ or META-INF/resources/: " + fileToCopy); + } + Path outputFile = outputDirectory.resolve(outputPath); + Files.createDirectories(outputFile.getParent()); + Files.copy(fileToCopy.getAbsolutePath(), outputFile); + } + @Override public String getOutputType() { return OutputTypes.OPTIMIZED_JS; @@ -51,22 +136,27 @@ public Task resolve(Project project, Config config) { // TODO filter to just JS and sourcemaps? probably not required unless we also get sources // from the actual input source instead of copying it along each step List jsSources = Stream.concat( - Stream.of( - input(project, OutputTypes.TRANSPILED_JS).filter(JS_SOURCES), - // BYTECODE will contains original and generated js sources - input(project, OutputTypes.BYTECODE).filter(PLAIN_JS_SOURCES) - ), - scope(project.getDependencies(), Dependency.Scope.RUNTIME) - .stream() - .flatMap(p -> { - return Stream.of( - input(p, OutputTypes.TRANSPILED_JS).filter(JS_SOURCES), - // generated sources will include original input sources - input(p, OutputTypes.BYTECODE).filter(PLAIN_JS_SOURCES) - ); - }) - ).collect(Collectors.toList()); + Stream.of(project), + scope(project.getDependencies(), Dependency.Scope.RUNTIME).stream() + ) + .flatMap(p -> Stream.of( + input(p, OutputTypes.TRANSPILED_JS), + // Bytecode sources will include original input sources + // as well as generated input when the jar was built + input(p, OutputTypes.BYTECODE) + )) + // Only include the JS and externs + .map(i -> i.filter(PLAIN_JS_SOURCES, EXTERNS)) + .collect(Collectors.toList()); + List outputToCopy = Stream.concat( + Stream.of(project), + scope(project.getDependencies(), Dependency.Scope.RUNTIME).stream() + ) + // Only need to consider the original inputs and generated sources, + // J2CL won't contribute this kind of sources + .map(p -> input(p, OutputTypes.BYTECODE).filter(COPIED_OUTPUT)) + .collect(Collectors.toList()); // grab configs we plan to use String compilationLevelConfig = config.getCompilationLevel(); @@ -164,6 +254,12 @@ public void finish(TaskContext taskContext) throws IOException { Files.createDirectories(webappDirectory); } FileUtils.copyDirectory(taskContext.outputPath().toFile(), webappDirectory.toFile()); + Path resourceOutputPath = webappDirectory.resolve(initialScriptFilename).getParent(); + for (Input input : outputToCopy) { + for (CachedPath entry : input.getFilesAndHashes()) { + copiedOutputPath(resourceOutputPath, entry); + } + } } }; } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/IJarTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/IJarTask.java index 4c383bcc..e7b42f89 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/IJarTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/IJarTask.java @@ -4,6 +4,7 @@ import com.vertispan.j2cl.build.task.*; import java.nio.file.Files; +import java.nio.file.Path; /** * TODO implement using the ijar tool or the equivelent @@ -34,8 +35,9 @@ public Task resolve(Project project, Config config) { // for now we're going to just copy the bytecode for (CachedPath path : myStrippedBytecode.getFilesAndHashes()) { - Files.createDirectories(context.outputPath().resolve(path.getSourcePath()).getParent()); - Files.copy(path.getAbsolutePath(), context.outputPath().resolve(path.getSourcePath())); + Path outputFile = context.outputPath().resolve(path.getSourcePath()); + Files.createDirectories(outputFile.getParent()); + Files.copy(path.getAbsolutePath(), outputFile); } }; } diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/JsZipBundleTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/JsZipBundleTask.java index bb9faff9..761c7627 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/JsZipBundleTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/JsZipBundleTask.java @@ -19,9 +19,12 @@ */ @AutoService(TaskFactory.class) public class JsZipBundleTask extends TaskFactory { + // While this is an internal task, it is still possible to provide an alternative implementation + public static final String JSZIP_BUNDLE_OUTPUT_TYPE = "jszipbundle"; + @Override public String getOutputType() { - return "jszipbundle"; + return JSZIP_BUNDLE_OUTPUT_TYPE; } @Override diff --git a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/TestCollectionTask.java b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/TestCollectionTask.java index 9a3ada98..f1c43cb6 100644 --- a/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/TestCollectionTask.java +++ b/j2cl-tasks/src/main/java/com/vertispan/j2cl/build/provided/TestCollectionTask.java @@ -45,8 +45,9 @@ public void execute(TaskContext context) throws Exception { // Or even better, merge? for (CachedPath entry : apt.getFilesAndHashes()) { - Files.createDirectories(context.outputPath().resolve(entry.getSourcePath()).getParent()); - Files.copy(entry.getAbsolutePath(), context.outputPath().resolve(entry.getSourcePath())); + Path outputFile = context.outputPath().resolve(entry.getSourcePath()); + Files.createDirectories(outputFile.getParent()); + Files.copy(entry.getAbsolutePath(), outputFile); } }