From bb50f6c643cf82bfdc3146cb36c54fec2e7f4562 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 30 Oct 2024 15:16:51 +0100 Subject: [PATCH 01/31] React: bump GraalJS and turn on sandboxing tests. This version fixes a JVM crash that occurs with the latest GraalVM, and removes the need for the prior test disables related to sandboxing. The JSON object ordering isn't stable across versions so fix the assumption that it is. --- gradle/libs.versions.toml | 2 +- views-react/build.gradle | 1 + .../views/react/SandboxReactRenderSpec.groovy | 28 +++++++++---------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd4035ecf..f4acd9b2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ managed-soy = "2023-09-13" org-json = "20240303" managed-thymeleaf = "3.1.2.RELEASE" managed-velocity = "2.4.1" -graal = "24.0.1" +graal = "24.1.1" pebble = "3.2.2" thymeleaf-extra-java8time = "3.0.4.RELEASE" diff --git a/views-react/build.gradle b/views-react/build.gradle index d92c40fc6..2bb785cf7 100644 --- a/views-react/build.gradle +++ b/views-react/build.gradle @@ -31,6 +31,7 @@ dependencies { testImplementation(mn.micronaut.management) testImplementation(mnValidation.micronaut.validation) testImplementation(mn.snakeyaml) + testImplementation(mn.groovy.json) // We will use enterprise edition for testing. testImplementation(libs.graal.js) diff --git a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy index 73a65cbff..b37ac19c9 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy @@ -1,11 +1,12 @@ package io.micronaut.views.react +import groovy.json.JsonSlurper import io.micronaut.context.annotation.Property -import io.micronaut.context.exceptions.BeanInstantiationException import io.micronaut.core.io.Writable +import io.micronaut.http.exceptions.MessageBodyException import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject -import spock.lang.FailsWith +import org.graalvm.polyglot.PolyglotException import spock.lang.Specification @MicronautTest(startApplication = false, rebuildContext = true) @@ -15,20 +16,22 @@ class SandboxReactRenderSpec extends Specification { @Inject ReactViewsRenderer renderer - // The version of GraalJS currently depended on is not compatible with the sandbox. When GraalJS is upgraded, - // this unit test can be enabled. - @FailsWith(BeanInstantiationException) void "views can be rendered with sandboxing enabled"() { when: Writable writable = renderer.render("App", TestProps.basic, null) - String result = new StringWriter().with { + String resultAsString = new StringWriter().with { writable.writeTo(it) it.toString() } + String dataJSON = resultAsString.find(~/var Micronaut = (\{[^;]+});/).replace("var Micronaut = ", "") + def data = new JsonSlurper().parseText(dataJSON) + then: - result.contains("Hello there") - result.contains("{\"name\":\"Mike\",\"obj\":{\"bar\":null,\"foo\":\"bar\"}}") + resultAsString.contains("Hello there") + data.rootProps.name == "Mike" + data.rootProps.obj.foo == "foo" + data.rootProps.obj.bar == null } void "host types are inaccessible with the sandbox enabled"() { @@ -40,11 +43,8 @@ class SandboxReactRenderSpec extends Specification { } then: - // The version of GraalJS currently depended on is not compatible with the sandbox. When GraalJS is upgraded, - // this unit test can be enabled. - thrown(BeanInstantiationException) -// def t = thrown(MessageBodyException) -// t.cause instanceof PolyglotException -// t.cause.message.contains("Java is not defined") + def t = thrown(MessageBodyException) + t.cause instanceof PolyglotException + t.cause.message.contains("Java is not defined") } } From 9ec50d2923b8a41dda48bd4de91220e66de7facf Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 30 Oct 2024 15:18:19 +0100 Subject: [PATCH 02/31] React: reactivate on CI now JVM crash is avoided. --- views-react/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/build.gradle b/views-react/build.gradle index 2bb785cf7..242a227e5 100644 --- a/views-react/build.gradle +++ b/views-react/build.gradle @@ -88,7 +88,7 @@ tasks.named("test") { // This module depends on GraalVM 21 or higher due to the need for the esm-exports GraalJS option and inability // to upgrade it on older JDKs that bundled languages together. onlyIf { - JavaVersion.current() >= JavaVersion.VERSION_21 && !System.getenv("CI") + JavaVersion.current() >= JavaVersion.VERSION_21 } } From 382bdafcf03968ab6830b4af17e273086bb0c432 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 11:49:26 +0200 Subject: [PATCH 03/31] ReactJS: Update README. --- views-react/README.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/views-react/README.md b/views-react/README.md index 9620518c1..4106ee4b0 100644 --- a/views-react/README.md +++ b/views-react/README.md @@ -1,17 +1,7 @@ # React SSR support for Micronaut -## TODO +This module uses GraalJS to implement server-side rendering for applications using React or similar libraries like Preact. -1. Eliminate all TODOs from the docs. -2. Make HTTP prefetches run in parallel. -3. Reduce the need for config: - 1. Work out what `micronaut.views.folder` is supposed to be when run from Maven. Get rid of the need to specify this. - 2. Make it configurable and allow the path to the static assets to be configured so it doesn't have to be served from MN itself. - 3. Get rid of the blocking of the event loop when prefetching. Pending answer from MN team about why IO pool switch isn't implemented. -4. Write unit tests. -5. Document what you can and cannot do in GraalJS. -6. Find a way to use `renderToPipeableStream`? -7. Replace `__micronaut_prefetch` with Sam's implementation of fetch() for Micronaut? -8. Document how to do debugging? -9. Implement / get implemented TextEncoder/TextDecoder -10. Update the micronaut-spa-app sample. +To get a project that uses it, try Micronaut Starter and request Views React. That will give you a fully functional +frontend project with build system integration (NPM and Webpack integrated with Maven and Gradle). You can then easily +add components and build up your frontend, or connect up an existing frontend codebase. From 10f6b560d95a20e249b824629d2fb6ff0bcc090a Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 11:59:00 +0200 Subject: [PATCH 04/31] ReactJS: Refactor file reload handler out of JSContextPool, mark some fields private. --- .../micronaut/views/react/JSBundlePaths.java | 32 +++++++++++++---- .../micronaut/views/react/JSContextPool.java | 36 ++++++++++--------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java index 6e4f73f86..fd355a3dd 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java @@ -15,13 +15,17 @@ */ package io.micronaut.views.react; +import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.ResourceResolver; -import io.micronaut.views.ViewsConfiguration; +import io.micronaut.scheduling.io.watch.event.FileChangedEvent; +import io.micronaut.scheduling.io.watch.event.WatchEventType; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graalvm.polyglot.Source; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.FileNotFoundException; @@ -34,27 +38,33 @@ import static java.lang.String.format; /** - * Wraps the computation of where to find the JS for client and server. + * Wraps the computation of where to find the JS for client and server. Hot reloads on file changes. */ @Singleton @Internal -class JSBundlePaths { +class JSBundlePaths implements ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(JSBundlePaths.class); + + private final JSContextPool contextPool; + // Source code file name, for JS stack traces. - final String bundleFileName; + private final String bundleFileName; // URL of bundle file, could be a file:// or in a classpath jar. - final URL bundleURL; + private final URL bundleURL; // If a file:// (during development), the path of that file. Used for hot reloads. @Nullable - final Path bundlePath; + private final Path bundlePath; @Inject JSBundlePaths( - ViewsConfiguration viewsConfiguration, + JSContextPool contextPool, ReactViewsRendererConfiguration reactConfiguration, ResourceResolver resolver ) throws IOException { + this.contextPool = contextPool; + Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); if (bundlePathOpt.isEmpty()) { throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); @@ -75,4 +85,12 @@ Source readServerBundle() throws IOException { .build(); } } + + @Override + public void onApplicationEvent(FileChangedEvent event) { + if (bundlePath != null && event.getPath().equals(bundlePath) && event.getEventType() != WatchEventType.DELETE) { + LOG.info("Reloading Javascript bundle due to file change."); + contextPool.releaseAll(); + } + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java b/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java index 035a79880..0f174f942 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java @@ -16,10 +16,7 @@ package io.micronaut.views.react; import io.micronaut.context.ApplicationContext; -import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; -import io.micronaut.scheduling.io.watch.event.FileChangedEvent; -import io.micronaut.scheduling.io.watch.event.WatchEventType; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.slf4j.Logger; @@ -37,19 +34,16 @@ */ @Singleton @Internal -class JSContextPool implements ApplicationEventListener { - private static final Logger LOG = LoggerFactory.getLogger(JSContextPool.class); +class JSContextPool { private final ApplicationContext applicationContext; - private final JSBundlePaths paths; // Synchronized on 'this'. private final LinkedList> contexts = new LinkedList<>(); private int versionCounter = 0; // File reloads. @Inject - JSContextPool(ApplicationContext applicationContext, JSBundlePaths paths) { + JSContextPool(ApplicationContext applicationContext) { this.applicationContext = applicationContext; - this.paths = paths; } /** @@ -75,16 +69,26 @@ synchronized JSContext acquire() { return applicationContext.createBean(JSContext.class, versionCounter); } + /** + * Puts a context back into the pool. It should be returned in a 'clean' state, so whatever + * thread picks it up next finds it ready to use and without any leftover data from prior + * usages. + */ synchronized void release(JSContext jsContext) { - // Put it back into the pool for reuse. - contexts.add(new SoftReference<>(jsContext)); + // Put it back into the pool for reuse unless it's out of date, in which case just let it drift. + if (jsContext.versionCounter == versionCounter) + contexts.add(new SoftReference<>(jsContext)); } - @Override - public synchronized void onApplicationEvent(FileChangedEvent event) { - if (paths.bundlePath != null && event.getPath().equals(paths.bundlePath) && event.getEventType() != WatchEventType.DELETE) { - LOG.info("Reloading Javascript bundle due to file change."); - versionCounter++; - } + /** + * Semantically this method empties the pool. The actual contexts won't be released until they + * are requested later, this implementation just marks them as out of date. Out of date contexts + * won't be re-added to the pool even if {@link #release(JSContext)} is called on them. It can + * be used if there is a need to reload all the contexts, e.g. because a file on disk changed. + */ + synchronized void releaseAll() { + versionCounter++; + if (versionCounter < 0) + throw new IllegalStateException("Version counter wrapped, you can't call releaseAll this many times."); } } From f80af4833b95dc1f9f5c2691b4b8c4f698017d77 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:36:39 +0200 Subject: [PATCH 05/31] ReactJS: Improve the docs for how to enable hot file reload. --- .../views/templates/react/reactsettingproperties.adoc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc b/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc index 39676a2b1..e084e3b9b 100644 --- a/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc +++ b/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc @@ -27,7 +27,7 @@ IMPORTANT: Watch out for the last property that disables virtual threads. If you [[react-dev-mode]] == Development -During development you want the fastest iteration speed possible. Firstly turn off response caching so hot reload works with `npx webpack --watch`. Micronaut Views React will automatically notice the file changed on disk and reload it. +During development you want the fastest iteration speed possible. These property changes will help: [configuration] ---- @@ -38,9 +38,15 @@ micronaut: responses: file: cache-seconds: 0 + io: + watch: + enabled: true + paths: build/resources/main/views ---- -If using Maven turn off Micronaut's automatic restart features so that changes to the compiled bundle JS don't cause the whole server to reboot: +The `paths` property is correct if you're using the default JS compilation setup created for you by Micronaut Starter. If your JS bundle is held in a different directory in your project, make sure to set the path appropriately. + +Now you can tell your build system to only recompile the needed files. In a Micronaut Starter based project that uses Gradle, just run `./gradlew --continuous processResources`. This will cause new bundles to be created for both client and server whenever your input JS changes. If using Maven turn off Micronaut's automatic restart features so that changes to the compiled bundle JS don't cause the whole server to reboot, and then make sure to re-run the Maven build when necessary: [xml] ---- @@ -61,4 +67,3 @@ If using Maven turn off Micronaut's automatic restart features so that changes t ---- - From 4ce83fab9bbe2ebf6ce5e394795482d86644d01e Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:37:28 +0200 Subject: [PATCH 06/31] ReactJS: Refactor JSContextPool into BeanPool. This is a new generic utility useful for not only other Truffle languages, but Micronaut in general. Improved the documentation, efficiency and usability of the class along the way. --- .../io/micronaut/views/react/BeanPool.java | 151 ++++++++++++++++++ .../io/micronaut/views/react/CompiledJS.java | 20 ++- .../micronaut/views/react/JSBeanFactory.java | 10 +- .../micronaut/views/react/JSBundlePaths.java | 26 +-- .../io/micronaut/views/react/JSContext.java | 11 +- .../micronaut/views/react/JSContextPool.java | 94 ----------- .../views/react/ReactViewsRenderer.java | 19 +-- .../IntrospectableBeansAreProxiedSpec.groovy | 6 +- 8 files changed, 196 insertions(+), 141 deletions(-) create mode 100644 views-react/src/main/java/io/micronaut/views/react/BeanPool.java delete mode 100644 views-react/src/main/java/io/micronaut/views/react/JSContextPool.java diff --git a/views-react/src/main/java/io/micronaut/views/react/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/BeanPool.java new file mode 100644 index 000000000..8f0998d70 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/BeanPool.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017-2020 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.react; + +import io.micronaut.core.annotation.Internal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.ref.SoftReference; +import java.util.LinkedList; +import java.util.function.Supplier; + +/** + *

An object pool for beans that are both expensive to construct and not thread safe.

+ * + *

+ * The pool vends them temporarily to threads that need them when they call {@link #checkOut()}. + * This pool is designed to work well with virtual threads, by avoiding thread locals or any + * assumptions about how many distinct threads will request objects. If all the beans are currently + * checked out and a new bean is requested it will be created using the factory provided to the + * constructor. Therefore the number of beans created and available in the pool should grow to match + * the general rate of contention, rather than how many threads are in use. + *

+ * + *

Note: this pool doesn't release objects in the background. If you have + * a sudden spike of traffic that drives many checkouts, memory usage may grow significantly + * and not be released. Fixing this would be a good future improvement to the pool.

+ */ +@Internal +public class BeanPool { + // TODO: Use @Scheduled to occasionally clear out beans that weren't accessed for a while to recover from traffic spikes. + + private static final Logger LOG = LoggerFactory.getLogger(BeanPool.class); + + private final Supplier factory; + + /** + * A handle to a pooled object. Call {@link #get()} to obtain the wrapped reference, and then + * pass this handle to {@link BeanPool#checkIn(Handle)} to put it back. Alternatively you can + * just close this object to check it back in. + */ + public interface Handle extends Supplier, AutoCloseable { + @Override + void close(); + } + + private final class PoolEntry implements Handle { + final T obj; + final int version; + + private PoolEntry(T obj, int version) { + this.obj = obj; + this.version = version; + } + + @Override + public T get() { + return obj; + } + + @Override + public void close() { + checkIn(this); + } + + @Override + public String toString() { + return "PoolEntry[" + + "obj=" + obj + ", " + + "version=" + version + ']'; + } + } + + // Synchronized on 'this'. + private final LinkedList> pool = new LinkedList<>(); + private int versionCounter = 0; // File reloads. + + /** + * Constructs an object pool that uses the given factory to create new entries. + * + * @param factory Used to create new entries when the pool is empty. Must be thread safe. + */ + public BeanPool(Supplier factory) { + this.factory = factory; + } + + /** + * Returns a cached bean or creates a new one. Call {@link Handle#get()} to obtain the + * underlying object. + * + * @return a wrapper for the entry that you should pass to {@link #checkIn(Handle)} when you're + * done with it to put it back into the pool. + */ + public synchronized Handle checkOut() { + while (!pool.isEmpty()) { + SoftReference ref = pool.poll(); + assert ref != null; + + PoolEntry handle = ref.get(); + + // The entry may have been garbage collected (== null), or it might be for an old + // version. In both cases we just let it drift away as we now hold the only reference. + LOG.info("handle {}", handle); + if (handle != null && handle.version == versionCounter) { + return handle; + } + } + + // No more pooled contexts available, create one and return it. It'll be added [back] to the + // pool when release() is called. + return new PoolEntry(factory.get(), versionCounter); + } + + /** + * Puts a context back into the pool. It should be returned in a 'clean' state, so whatever + * thread picks it up next finds it ready to use and without any leftover data from prior + * usages. + * + * @param handle The object you got from {@link #checkOut()}. + */ + public synchronized void checkIn(Handle handle) { + var impl = (PoolEntry) handle; + // Put it back into the pool for reuse unless it's out of date, in which case just let it drift. + if (impl.version == versionCounter) + pool.add(new SoftReference<>(impl)); + } + + /** + * Empties the pool. Beans currently checked out with {@link #checkOut()} will not be re-added + * to the pool when {@link #checkIn(Handle)} is called. + */ + public synchronized void clear() { + versionCounter++; + if (versionCounter < 0) + throw new IllegalStateException("Version counter wrapped, you can't call releaseAll this many times."); + pool.clear(); + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java index 54bb299c3..73a14cf29 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -15,7 +15,10 @@ */ package io.micronaut.views.react; +import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; +import io.micronaut.scheduling.io.watch.event.FileChangedEvent; +import io.micronaut.scheduling.io.watch.event.WatchEventType; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -33,21 +36,23 @@ */ @Singleton @Internal -class CompiledJS implements AutoCloseable { +class CompiledJS implements AutoCloseable, ApplicationEventListener { private static final Logger LOG = LoggerFactory.getLogger("js"); final Engine engine; + private final BeanPool beanPool; private Source source; private final JSBundlePaths jsBundlePaths; @Inject - CompiledJS(JSBundlePaths jsBundlePaths, JSEngineLogHandler engineLogHandler, JSSandboxing sandboxing) { + CompiledJS(JSBundlePaths jsBundlePaths, JSEngineLogHandler engineLogHandler, JSSandboxing sandboxing, BeanPool beanPool) { var engineBuilder = Engine.newBuilder("js") .out(new OutputStreamToSLF4J(LOG, Level.INFO)) .err(new OutputStreamToSLF4J(LOG, Level.ERROR)) .logHandler(engineLogHandler); engine = sandboxing.configure(engineBuilder).build(); this.jsBundlePaths = jsBundlePaths; + this.beanPool = beanPool; reload(); } @@ -55,7 +60,7 @@ synchronized Source getSource() { return source; } - synchronized void reload() { + private synchronized void reload() { try { source = jsBundlePaths.readServerBundle(); } catch (IOException e) { @@ -68,4 +73,13 @@ synchronized void reload() { public void close() throws Exception { engine.close(); } + + @Override + public void onApplicationEvent(FileChangedEvent event) { + if (jsBundlePaths.bundlePath != null && event.getPath().equals(jsBundlePaths.bundlePath) && event.getEventType() != WatchEventType.DELETE) { + LOG.info("Reloading Javascript bundle due to file change."); + reload(); + beanPool.clear(); + } + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 972930924..80c4d2535 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -15,11 +15,14 @@ */ package io.micronaut.views.react; -import jakarta.inject.Singleton;; +import io.micronaut.context.ApplicationContext; import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; +import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; +; + /** * Allows the default Javascript context and host access policy to be controlled. */ @@ -40,4 +43,9 @@ HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { ? HostAccess.CONSTRAINED : HostAccess.ALL; } + + @Singleton + BeanPool contextPool(ApplicationContext applicationContext) { + return new BeanPool<>(() -> applicationContext.createBean(JSContext.class)); + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java index fd355a3dd..19afab1a1 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java @@ -15,12 +15,9 @@ */ package io.micronaut.views.react; -import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.ResourceResolver; -import io.micronaut.scheduling.io.watch.event.FileChangedEvent; -import io.micronaut.scheduling.io.watch.event.WatchEventType; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graalvm.polyglot.Source; @@ -42,11 +39,9 @@ */ @Singleton @Internal -class JSBundlePaths implements ApplicationEventListener { +class JSBundlePaths { private static final Logger LOG = LoggerFactory.getLogger(JSBundlePaths.class); - private final JSContextPool contextPool; - // Source code file name, for JS stack traces. private final String bundleFileName; @@ -55,16 +50,10 @@ class JSBundlePaths implements ApplicationEventListener { // If a file:// (during development), the path of that file. Used for hot reloads. @Nullable - private final Path bundlePath; + final Path bundlePath; @Inject - JSBundlePaths( - JSContextPool contextPool, - ReactViewsRendererConfiguration reactConfiguration, - ResourceResolver resolver - ) throws IOException { - this.contextPool = contextPool; - + JSBundlePaths(ReactViewsRendererConfiguration reactConfiguration, ResourceResolver resolver) throws IOException { Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); if (bundlePathOpt.isEmpty()) { throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); @@ -73,6 +62,7 @@ class JSBundlePaths implements ApplicationEventListener { bundleFileName = bundleURL.getFile(); if (bundleURL.getProtocol().equals("file")) { bundlePath = Path.of(bundleURL.getPath()); + LOG.info("Using server-side JS bundle from local disk: {}", bundlePath); } else { bundlePath = null; } @@ -85,12 +75,4 @@ Source readServerBundle() throws IOException { .build(); } } - - @Override - public void onApplicationEvent(FileChangedEvent event) { - if (bundlePath != null && event.getPath().equals(bundlePath) && event.getEventType() != WatchEventType.DELETE) { - LOG.info("Reloading Javascript bundle due to file change."); - contextPool.releaseAll(); - } - } } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContext.java b/views-react/src/main/java/io/micronaut/views/react/JSContext.java index de87c7510..93f32013c 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSContext.java @@ -15,7 +15,7 @@ */ package io.micronaut.views.react; -import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Bean; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; import jakarta.annotation.PostConstruct; @@ -37,6 +37,7 @@ * environment usable by one thread at a time. */ @Internal +@Bean class JSContext implements AutoCloseable { // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); @@ -46,19 +47,15 @@ class JSContext implements AutoCloseable { Value render; Value ssrModule; - // What version of the on-disk bundle (considering file change events) we were loaded from. - final int versionCounter; - private final CompiledJS compiledJS; private final ReactViewsRendererConfiguration configuration; private final JSSandboxing sandboxing; @Inject - JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, JSSandboxing sandboxing, @Parameter int versionCounter) { + JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, JSSandboxing sandboxing) { this.compiledJS = compiledJS; this.configuration = configuration; this.sandboxing = sandboxing; - this.versionCounter = versionCounter; } @PostConstruct @@ -137,7 +134,7 @@ private Context createContext() { throw e; } } catch (IllegalArgumentException e) { - // We need esm-eval-returns-exports=true but it's not compatible with the sandbox in this version of GraalJS. + // We need esm-eval-returns-exports=true, but it's not compatible with the sandbox in this version of GraalJS. if (e.getMessage().contains("Option 'js.esm-eval-returns-exports' is experimental")) { throw new IllegalStateException("The sandboxing feature requires a newer version of GraalJS. Please upgrade and try again, or disable the sandboxing feature."); } else { diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java b/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java deleted file mode 100644 index 0f174f942..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.micronaut.views.react; - -import io.micronaut.context.ApplicationContext; -import io.micronaut.core.annotation.Internal; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.ref.SoftReference; -import java.util.LinkedList; - -/** - * Vends contexts to threads that need them. We don't use ThreadLocals here because what matters - * is contention. If there are 30 server threads, but only a few requests ever use React, then - * we don't want to have 30 contexts in memory at all times because they are quite chunky objects. - * By only creating more when we are genuinely under load, we avoid bloat. This also fits better - * with virtual threads, where a thread may not live beyond the lifetime of a single request. - */ -@Singleton -@Internal -class JSContextPool { - private final ApplicationContext applicationContext; - - // Synchronized on 'this'. - private final LinkedList> contexts = new LinkedList<>(); - private int versionCounter = 0; // File reloads. - - @Inject - JSContextPool(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - - /** - * Returns a cached context or creates a new one. You must give the JSContext to - * {@link #release(JSContext)} when you're done with it to put it (back) into the pool. - */ - synchronized JSContext acquire() { - while (!contexts.isEmpty()) { - SoftReference ref = contexts.poll(); - assert ref != null; - - var context = ref.get(); - // context may have been garbage collected (== null), or it might be for an old - // version of the on-disk bundle. In both cases we just let it drift away as we - // now hold the only reference. - if (context != null && context.versionCounter == versionCounter) { - return context; - } - } - - // No more pooled contexts available, create one and return it. It'll be added [back] to the - // pool when release() is called. - return applicationContext.createBean(JSContext.class, versionCounter); - } - - /** - * Puts a context back into the pool. It should be returned in a 'clean' state, so whatever - * thread picks it up next finds it ready to use and without any leftover data from prior - * usages. - */ - synchronized void release(JSContext jsContext) { - // Put it back into the pool for reuse unless it's out of date, in which case just let it drift. - if (jsContext.versionCounter == versionCounter) - contexts.add(new SoftReference<>(jsContext)); - } - - /** - * Semantically this method empties the pool. The actual contexts won't be released until they - * are requested later, this implementation just marks them as out of date. Out of date contexts - * won't be re-added to the pool even if {@link #release(JSContext)} is called on them. It can - * be used if there is a need to reload all the contexts, e.g. because a file on disk changed. - */ - synchronized void releaseAll() { - versionCounter++; - if (versionCounter < 0) - throw new IllegalStateException("Version counter wrapped, you can't call releaseAll this many times."); - } -} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 612b36d89..807271aad 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -15,6 +15,7 @@ */ package io.micronaut.views.react; +import io.micronaut.context.exceptions.BeanInstantiationException; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.Writable; @@ -42,7 +43,7 @@ public class ReactViewsRenderer implements ViewsRenderer contextPool; /** * Construct this renderer. Don't call it yourself, as Micronaut Views will set it up for you. @@ -63,25 +64,21 @@ public ReactViewsRenderer() { @Override public @NonNull Writable render(@NonNull String viewName, @Nullable PROPS props, @Nullable HttpRequest request) { return writer -> { - JSContext context = contextPool.acquire(); - try { - render(viewName, props, writer, context, request); + try (BeanPool.Handle contextHandle = contextPool.checkOut()) { + render(viewName, props, writer, contextHandle.get(), request); + } catch (BeanInstantiationException e) { + throw e; } catch (Exception e) { // If we don't wrap and rethrow, the exception is swallowed and the request hangs. throw new MessageBodyException("Could not render component " + viewName, e); - } finally { - contextPool.release(context); } }; } @Override public boolean exists(@NonNull String viewName) { - var context = contextPool.acquire(); - try { - return context.moduleHasMember(viewName); - } finally { - contextPool.release(context); + try (var contextHandle = contextPool.checkOut()) { + return contextHandle.get().moduleHasMember(viewName); } } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy index d84cf8277..1a5672546 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy @@ -9,12 +9,12 @@ import spock.lang.Specification @MicronautTest(startApplication = false) class IntrospectableBeansAreProxiedSpec extends Specification { @Inject - JSContextPool contextPool + BeanPool contextPool void "introspectable bean can be proxied"() { given: - def jsContext = contextPool.acquire() - def context = jsContext.polyglotContext + BeanPool.Handle jsContext = contextPool.checkOut() + def context = jsContext.get().polyglotContext def bean = new SomeBean("foo value", "bar value", new SomeBean.InnerBean(10, Map.of("key", 123), List.of("one", "two", "three"))) when: From c5d29166fb8f01ef7f13b94d4636b13b4cd46f4a Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:38:03 +0200 Subject: [PATCH 07/31] ReactJS: Move BeanPool into a sub-package. --- .../src/main/java/io/micronaut/views/react/CompiledJS.java | 1 + .../src/main/java/io/micronaut/views/react/JSBeanFactory.java | 1 + .../main/java/io/micronaut/views/react/ReactViewsRenderer.java | 1 + .../java/io/micronaut/views/react/{ => util}/BeanPool.java | 3 +-- .../views/react/IntrospectableBeansAreProxiedSpec.groovy | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{ => util}/BeanPool.java (98%) diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java index 73a14cf29..d2ad4318f 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -19,6 +19,7 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; +import io.micronaut.views.react.util.BeanPool; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 80c4d2535..26768eeac 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -18,6 +18,7 @@ import io.micronaut.context.ApplicationContext; import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; +import io.micronaut.views.react.util.BeanPool; import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 807271aad..4c8fbe976 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -22,6 +22,7 @@ import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; import io.micronaut.views.ViewsRenderer; +import io.micronaut.views.react.util.BeanPool; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; diff --git a/views-react/src/main/java/io/micronaut/views/react/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java similarity index 98% rename from views-react/src/main/java/io/micronaut/views/react/BeanPool.java rename to views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java index 8f0998d70..fd0badcc0 100644 --- a/views-react/src/main/java/io/micronaut/views/react/BeanPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.views.react; +package io.micronaut.views.react.util; import io.micronaut.core.annotation.Internal; import org.slf4j.Logger; @@ -113,7 +113,6 @@ public synchronized Handle checkOut() { // The entry may have been garbage collected (== null), or it might be for an old // version. In both cases we just let it drift away as we now hold the only reference. - LOG.info("handle {}", handle); if (handle != null && handle.version == versionCounter) { return handle; } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy index 1a5672546..8e34ed618 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy @@ -1,6 +1,7 @@ package io.micronaut.views.react import io.micronaut.test.extensions.spock.annotation.MicronautTest +import io.micronaut.views.react.util.BeanPool import jakarta.inject.Inject import org.graalvm.polyglot.Value import org.graalvm.polyglot.proxy.ProxyObject From e8ff5140d9845897143f9ca3a79ffa3f4bd24aac Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:39:26 +0200 Subject: [PATCH 08/31] ReactJS: Move OutputStreamToSLF4J into the utils sub-package. This class is generally useful and could be a part of Micronaut (or really, SLF4J itself). --- .../src/main/java/io/micronaut/views/react/CompiledJS.java | 1 + .../micronaut/views/react/{ => util}/OutputStreamToSLF4J.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{ => util}/OutputStreamToSLF4J.java (96%) diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java index d2ad4318f..9c7ac8833 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -20,6 +20,7 @@ import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.react.util.BeanPool; +import io.micronaut.views.react.util.OutputStreamToSLF4J; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/views-react/src/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java b/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java similarity index 96% rename from views-react/src/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java rename to views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java index 077657f75..1f4101afa 100644 --- a/views-react/src/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.views.react; +package io.micronaut.views.react.util; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; @@ -30,7 +30,7 @@ * An output stream that looks for line separators and then writes out the lines of text to the given logger. */ @Internal -final class OutputStreamToSLF4J extends OutputStream { +public final class OutputStreamToSLF4J extends OutputStream { private final Charset charset; private ByteBuffer buffer = ByteBuffer.allocate(512); From 5a7c4c9823fef3b32f3f2dc957b82ee3dea07b58 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:41:22 +0200 Subject: [PATCH 09/31] ReactJS: Move the JUL2SLF4J adapter into the utils sub-package. This class is generally useful and could be a part of Micronaut (or really, SLF4J itself). --- .../io/micronaut/views/react/CompiledJS.java | 5 ++-- .../JavaUtilLoggingToSLF4J.java} | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{JSEngineLogHandler.java => util/JavaUtilLoggingToSLF4J.java} (71%) diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java index 9c7ac8833..ed091ade8 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -20,6 +20,7 @@ import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.react.util.BeanPool; +import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; import io.micronaut.views.react.util.OutputStreamToSLF4J; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; @@ -47,11 +48,11 @@ class CompiledJS implements AutoCloseable, ApplicationEventListener beanPool) { + CompiledJS(JSBundlePaths jsBundlePaths, JSSandboxing sandboxing, BeanPool beanPool) { var engineBuilder = Engine.newBuilder("js") .out(new OutputStreamToSLF4J(LOG, Level.INFO)) .err(new OutputStreamToSLF4J(LOG, Level.ERROR)) - .logHandler(engineLogHandler); + .logHandler(new JavaUtilLoggingToSLF4J(LOG)); engine = sandboxing.configure(engineBuilder).build(); this.jsBundlePaths = jsBundlePaths; this.beanPool = beanPool; diff --git a/views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java b/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java similarity index 71% rename from views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java rename to views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java index ecba254c0..f34656bfc 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java @@ -13,12 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.views.react; +package io.micronaut.views.react.util; import io.micronaut.core.annotation.Internal; -import jakarta.inject.Singleton; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.logging.Handler; import java.util.logging.LogRecord; @@ -27,10 +25,13 @@ * Forwards/redirects log messages from the GraalJS / Truffle engines themselves to SLF4J. * Note that Javascript's {@code console.log()} is handled differently. */ -@Singleton @Internal -class JSEngineLogHandler extends Handler { - private static final Logger LOG = LoggerFactory.getLogger(ReactViewsRenderer.class); +public class JavaUtilLoggingToSLF4J extends Handler { + private final Logger logger; + + public JavaUtilLoggingToSLF4J(Logger logger) { + this.logger = logger; + } @Override public void publish(LogRecord record) { @@ -38,11 +39,11 @@ public void publish(LogRecord record) { Throwable thrown = record.getThrown(); String level = record.getLevel().getName(); switch (level) { - case "SEVERE" -> LOG.error(message, thrown); - case "WARNING" -> LOG.warn(message, thrown); - case "INFO" -> LOG.info(message, thrown); - case "CONFIG", "FINE" -> LOG.debug(message, thrown); - case "FINER", "FINEST" -> LOG.trace(message, thrown); + case "SEVERE" -> logger.error(message, thrown); + case "WARNING" -> logger.warn(message, thrown); + case "INFO" -> logger.info(message, thrown); + case "CONFIG", "FINE" -> logger.debug(message, thrown); + case "FINER", "FINEST" -> logger.trace(message, thrown); default -> throw new IllegalStateException("Unexpected value: " + level); } } From be7f13d4b51445e84a7a308ebd9628761b2c1ca0 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 14:42:31 +0200 Subject: [PATCH 10/31] ReactJS: Move and rename the proxy adapter class. --- .../micronaut/views/react/ReactViewsRenderer.java | 3 ++- .../IntrospectableToTruffleAdapter.java} | 15 ++++++++------- .../IntrospectableBeansAreProxiedSpec.groovy | 5 +++-- 3 files changed, 13 insertions(+), 10 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{ProxyObjectWithIntrospectableSupport.java => truffle/IntrospectableToTruffleAdapter.java} (90%) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 4c8fbe976..bffe80817 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -22,6 +22,7 @@ import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; import io.micronaut.views.ViewsRenderer; +import io.micronaut.views.react.truffle.IntrospectableToTruffleAdapter; import io.micronaut.views.react.util.BeanPool; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -94,7 +95,7 @@ private void render(String componentName, PROPS props, Writer writer, JSContext // We wrap the props object so we can use Micronaut's compile-time reflection implementation. // This should be more native-image friendly (no need to write reflection config files), and // might also be faster. - Value guestProps = ProxyObjectWithIntrospectableSupport.wrap(context.polyglotContext, props); + Value guestProps = IntrospectableToTruffleAdapter.wrap(context.polyglotContext, props); context.render.executeVoid(component, guestProps, renderCallback, reactConfiguration.getClientBundleURL(), request); } diff --git a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java similarity index 90% rename from views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java rename to views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java index 1d9d3f6c7..cb858b7f9 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java +++ b/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.views.react; +package io.micronaut.views.react.truffle; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Nullable; @@ -39,23 +39,23 @@ * the regular polyglot mapping. */ @Internal -final class ProxyObjectWithIntrospectableSupport implements ProxyObject { +public final class IntrospectableToTruffleAdapter implements ProxyObject { private final Context context; private final Object target; @Nullable private final BeanIntrospection introspection; - private ProxyObjectWithIntrospectableSupport(Context context, Object target, BeanIntrospection introspection) { + private IntrospectableToTruffleAdapter(Context context, Object target, BeanIntrospection introspection) { this.context = context; this.target = target; this.introspection = introspection; } /** - * Returns an object as a Truffle {@link Value} suitable for guest access, wrapping introspectable types with {@link ProxyObjectWithIntrospectableSupport}. + * Returns an object as a Truffle {@link Value} suitable for guest access, wrapping introspectable types with {@link IntrospectableToTruffleAdapter}. */ - static Value wrap(Context context, Object object) { + public static Value wrap(Context context, Object object) { if (object == null) { return context.asValue(null); } else if (object instanceof Map map) { @@ -67,12 +67,13 @@ static Value wrap(Context context, Object object) { // We need to recursively map the items. This could be lazy. return context.asValue(collection.stream().map(it -> wrap(context, it)).toList()); } else if (object instanceof String) { - // We could ignore this case because we'd fall through the BeanIntrospector check, but that logs some debug spam and it's slower to look up objects we know we won't wrap anyway. + // We could ignore this case because we'd fall through the BeanIntrospector check, but that logs some debug spam, + // and it's slower to look up objects we know we won't wrap anyway. return context.asValue(object); } else { var introspection = BeanIntrospector.SHARED.findIntrospection(object.getClass()).orElse(null); if (introspection != null) { - return context.asValue(new ProxyObjectWithIntrospectableSupport(context, object, introspection)); + return context.asValue(new IntrospectableToTruffleAdapter(context, object, introspection)); } else { return context.asValue(object); } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy index 8e34ed618..5f4cc2be5 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy @@ -1,6 +1,7 @@ package io.micronaut.views.react import io.micronaut.test.extensions.spock.annotation.MicronautTest +import io.micronaut.views.react.truffle.IntrospectableToTruffleAdapter import io.micronaut.views.react.util.BeanPool import jakarta.inject.Inject import org.graalvm.polyglot.Value @@ -19,7 +20,7 @@ class IntrospectableBeansAreProxiedSpec extends Specification { def bean = new SomeBean("foo value", "bar value", new SomeBean.InnerBean(10, Map.of("key", 123), List.of("one", "two", "three"))) when: - ProxyObject proxy = ProxyObjectWithIntrospectableSupport.wrap(context, bean).asProxyObject() + ProxyObject proxy = IntrospectableToTruffleAdapter.wrap(context, bean).asProxyObject() context.getBindings("js").putMember("bean", proxy) then: @@ -30,7 +31,7 @@ class IntrospectableBeansAreProxiedSpec extends Specification { ProxyObject innerBean = ((Value) proxy.getMember("innerBean")).asProxyObject() then: - innerBean instanceof ProxyObjectWithIntrospectableSupport + innerBean instanceof IntrospectableToTruffleAdapter when: ProxyObject innerBeanMap = ((Value) innerBean.getMember("map")).asProxyObject() From ec77c8d65fb385b152c961b34a3e4e0d87c73797 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:12:15 +0200 Subject: [PATCH 11/31] ReactJS: Make BeanPool optionally close beans when they are cleared or obsoleted. --- .../micronaut/views/react/util/BeanPool.java | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java index fd0badcc0..b9dc4076e 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -19,6 +19,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.Closeable; +import java.io.IOException; import java.lang.ref.SoftReference; import java.util.LinkedList; import java.util.function.Supplier; @@ -35,9 +37,17 @@ * the general rate of contention, rather than how many threads are in use. *

* - *

Note: this pool doesn't release objects in the background. If you have + *

+ * Beans in the pool can be atomically cleared and closed (if they implement {@link Closeable}). + * Any beans checked out when {@link #clear()} is called will remain untouched, but when they are + * checked back in they will be closed and discarded at that point. + *

+ * + *

+ * Note: this pool doesn't release objects in the background. If you have * a sudden spike of traffic that drives many checkouts, memory usage may grow significantly - * and not be released. Fixing this would be a good future improvement to the pool.

+ * and not be released. Fixing this would be a good future improvement to the pool. + *

*/ @Internal public class BeanPool { @@ -126,25 +136,44 @@ public synchronized Handle checkOut() { /** * Puts a context back into the pool. It should be returned in a 'clean' state, so whatever * thread picks it up next finds it ready to use and without any leftover data from prior - * usages. + * usages. If the pool has been {@link #clear() cleared} previously, and the pooled object is + * {@link Closeable}, then the object will be closed at this point (exceptions are ignored). * * @param handle The object you got from {@link #checkOut()}. */ public synchronized void checkIn(Handle handle) { var impl = (PoolEntry) handle; // Put it back into the pool for reuse unless it's out of date, in which case just let it drift. - if (impl.version == versionCounter) + if (impl.version == versionCounter) { pool.add(new SoftReference<>(impl)); + } else if (impl.obj instanceof Closeable closeable) { + try { + closeable.close(); + } catch (IOException ignored) { + } + } } /** * Empties the pool. Beans currently checked out with {@link #checkOut()} will not be re-added - * to the pool when {@link #checkIn(Handle)} is called. + * to the pool when {@link #checkIn(Handle)} is called, and may be closed if they are + * {@link Closeable}. Likewise, all beans in the pool are closed if they are {@link Closeable} + * and exceptions thrown by {@link Closeable#close()} are ignored. */ public synchronized void clear() { versionCounter++; - if (versionCounter < 0) + if (versionCounter < 0) { throw new IllegalStateException("Version counter wrapped, you can't call releaseAll this many times."); + } + for (SoftReference ref : pool) { + var r = ref.get(); + if (r != null && r.obj instanceof Closeable closeable) { + try { + closeable.close(); + } catch (IOException ignored) { + } + } + } pool.clear(); } } From 9311f337422ec114403924286a7d422177ad1593 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:13:56 +0200 Subject: [PATCH 12/31] ReactJS: Fix an obsolete JavaDoc. --- .../src/main/java/io/micronaut/views/react/JSBundlePaths.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java index 19afab1a1..eef084131 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java @@ -35,7 +35,7 @@ import static java.lang.String.format; /** - * Wraps the computation of where to find the JS for client and server. Hot reloads on file changes. + * Wraps the computation of where to find the JS for client and server. */ @Singleton @Internal From c31023e093909ea8778768129aa6c5df23b6a46f Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:30:09 +0200 Subject: [PATCH 13/31] ReactJS: Refactoring to move Engine creation to a bean factory. This makes the code a bit more generic. --- .../io/micronaut/views/react/CompiledJS.java | 11 +--- .../micronaut/views/react/JSBeanFactory.java | 23 +++++++- .../io/micronaut/views/react/JSContext.java | 31 +++++++--- .../micronaut/views/react/JSSandboxing.java | 59 ------------------- 4 files changed, 46 insertions(+), 78 deletions(-) delete mode 100644 views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java index ed091ade8..b6c5f4d40 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -20,8 +20,6 @@ import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.react.util.BeanPool; -import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; -import io.micronaut.views.react.util.OutputStreamToSLF4J; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -29,7 +27,6 @@ import org.graalvm.polyglot.Source; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.slf4j.event.Level; import java.io.IOException; @@ -48,13 +45,9 @@ class CompiledJS implements AutoCloseable, ApplicationEventListener beanPool) { - var engineBuilder = Engine.newBuilder("js") - .out(new OutputStreamToSLF4J(LOG, Level.INFO)) - .err(new OutputStreamToSLF4J(LOG, Level.ERROR)) - .logHandler(new JavaUtilLoggingToSLF4J(LOG)); - engine = sandboxing.configure(engineBuilder).build(); + CompiledJS(JSBundlePaths jsBundlePaths, Engine engine, BeanPool beanPool) { this.jsBundlePaths = jsBundlePaths; + this.engine = engine; this.beanPool = beanPool; reload(); } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 26768eeac..41c3856b1 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -19,10 +19,15 @@ import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; import io.micronaut.views.react.util.BeanPool; +import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; +import io.micronaut.views.react.util.OutputStreamToSLF4J; import jakarta.inject.Singleton; +import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; - -; +import org.graalvm.polyglot.SandboxPolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; /** * Allows the default Javascript context and host access policy to be controlled. @@ -30,6 +35,8 @@ @Factory @Internal class JSBeanFactory { + private static final Logger LOG = LoggerFactory.getLogger("js"); + /** * This defaults to * {@link HostAccess#ALL} if the sandbox is disabled, or {@link HostAccess#CONSTRAINED} if it's on. @@ -49,4 +56,16 @@ HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { BeanPool contextPool(ApplicationContext applicationContext) { return new BeanPool<>(() -> applicationContext.createBean(JSContext.class)); } + + @Singleton + Engine engine(ReactViewsRendererConfiguration configuration) { + boolean sandbox = configuration.getSandbox(); + LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); + return Engine.newBuilder("js") + .out(new OutputStreamToSLF4J(LOG, Level.INFO)) + .err(new OutputStreamToSLF4J(LOG, Level.ERROR)) + .logHandler(new JavaUtilLoggingToSLF4J(LOG)) + .sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED) + .build(); + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContext.java b/views-react/src/main/java/io/micronaut/views/react/JSContext.java index 93f32013c..cb25c0327 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSContext.java @@ -21,9 +21,7 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.Source; -import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.*; import java.io.IOException; import java.nio.file.Files; @@ -41,6 +39,8 @@ class JSContext implements AutoCloseable { // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); + private final Engine engine; + private final HostAccess hostAccess; // Accessed from ReactViewsRenderer Context polyglotContext; @@ -49,13 +49,13 @@ class JSContext implements AutoCloseable { private final CompiledJS compiledJS; private final ReactViewsRendererConfiguration configuration; - private final JSSandboxing sandboxing; @Inject - JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, JSSandboxing sandboxing) { + JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, Engine engine, HostAccess hostAccess) { this.compiledJS = compiledJS; this.configuration = configuration; - this.sandboxing = sandboxing; + this.engine = engine; + this.hostAccess = hostAccess; } @PostConstruct @@ -121,11 +121,26 @@ private Source loadRenderSource() throws IOException { private Context createContext() { var contextBuilder = Context.newBuilder() - .engine(compiledJS.engine) + .engine(engine) .option("js.esm-eval-returns-exports", "true") .option("js.unhandled-rejections", "throw"); + + if (configuration.getSandbox()) { + contextBuilder + .sandbox(SandboxPolicy.CONSTRAINED) + .allowHostAccess(hostAccess); + } else { + // allowExperimentalOptions is here because as of the time of writing (August 2024) + // the esm-eval-returns-exports option is experimental. That got fixed and this + // can be removed once the base version of GraalJS is bumped to 24.1 or higher. + contextBuilder + .sandbox(SandboxPolicy.TRUSTED) + .allowAllAccess(true) + .allowExperimentalOptions(true); + } + try { - return sandboxing.configure(contextBuilder).build(); + return contextBuilder.build(); } catch (ExceptionInInitializerError e) { // The catch handler is to work around a bug in Polyglot 24.0.0 if (e.getCause().getMessage().contains("version compatibility check failed")) { diff --git a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java deleted file mode 100644 index 3610735e8..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.micronaut.views.react; - -import io.micronaut.core.annotation.Internal; -import jakarta.inject.Singleton; -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.Engine; -import org.graalvm.polyglot.HostAccess; -import org.graalvm.polyglot.SandboxPolicy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Some internal wrappers useful for centralizing sandbox configuration. - */ -@Singleton -@Internal -class JSSandboxing { - private static final Logger LOG = LoggerFactory.getLogger(JSSandboxing.class); - private final boolean sandbox; - private final HostAccess hostAccess; - - JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) { - sandbox = configuration.getSandbox(); - if (LOG.isDebugEnabled()) { - LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); - } - this.hostAccess = hostAccess; - } - - Engine.Builder configure(Engine.Builder engineBuilder) { - return engineBuilder.sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED); - } - - Context.Builder configure(Context.Builder builder) { - if (sandbox) { - return builder.sandbox(SandboxPolicy.CONSTRAINED).allowHostAccess(hostAccess); - } else { - // allowExperimentalOptions is here because as of the time of writing (August 2024) - // the esm-eval-returns-exports option is experimental. That got fixed and this - // can be removed once the base version of GraalJS is bumped to 24.1 or higher. - return builder.sandbox(SandboxPolicy.TRUSTED).allowAllAccess(true).allowExperimentalOptions(true); - } - } -} From ed7c4d679a1ec45d4865b8d3a5e653183c45437d Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:31:08 +0200 Subject: [PATCH 14/31] ReactJS: Bugfix to the sandbox policy. List/map access wasn't previously allowed, so in sandbox mode lists and maps wouldn't be exposed to JS and would appear empty. --- .../src/main/java/io/micronaut/views/react/JSBeanFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 41c3856b1..339fb6ce1 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -48,7 +48,7 @@ class JSBeanFactory { @Singleton HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { return configuration.getSandbox() - ? HostAccess.CONSTRAINED + ? HostAccess.newBuilder(HostAccess.CONSTRAINED).allowListAccess(true).allowMapAccess(true).build() : HostAccess.ALL; } From 42cc120875ce1cc6a8a26c61023362ee7606bebe Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:36:04 +0200 Subject: [PATCH 15/31] ReactJS: Rename some classes to make it clear they are React specific and not JS specific. --- .../{CompiledJS.java => CompiledReactJSBundle.java} | 10 +++++----- .../{JSBeanFactory.java => ReactJSBeanFactory.java} | 6 +++--- .../{JSBundlePaths.java => ReactJSBundlePaths.java} | 6 +++--- .../react/{JSContext.java => ReactJSContext.java} | 6 +++--- .../io/micronaut/views/react/ReactViewsRenderer.java | 6 +++--- .../react/IntrospectableBeansAreProxiedSpec.groovy | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{CompiledJS.java => CompiledReactJSBundle.java} (86%) rename views-react/src/main/java/io/micronaut/views/react/{JSBeanFactory.java => ReactJSBeanFactory.java} (95%) rename views-react/src/main/java/io/micronaut/views/react/{JSBundlePaths.java => ReactJSBundlePaths.java} (91%) rename views-react/src/main/java/io/micronaut/views/react/{JSContext.java => ReactJSContext.java} (96%) diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java similarity index 86% rename from views-react/src/main/java/io/micronaut/views/react/CompiledJS.java rename to views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java index b6c5f4d40..52581a7e8 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java @@ -36,16 +36,16 @@ */ @Singleton @Internal -class CompiledJS implements AutoCloseable, ApplicationEventListener { +class CompiledReactJSBundle implements AutoCloseable, ApplicationEventListener { private static final Logger LOG = LoggerFactory.getLogger("js"); - final Engine engine; - private final BeanPool beanPool; + private final Engine engine; + private final BeanPool beanPool; private Source source; - private final JSBundlePaths jsBundlePaths; + private final ReactJSBundlePaths jsBundlePaths; @Inject - CompiledJS(JSBundlePaths jsBundlePaths, Engine engine, BeanPool beanPool) { + CompiledReactJSBundle(ReactJSBundlePaths jsBundlePaths, Engine engine, BeanPool beanPool) { this.jsBundlePaths = jsBundlePaths; this.engine = engine; this.beanPool = beanPool; diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java similarity index 95% rename from views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java rename to views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 339fb6ce1..0e6f5d528 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -34,7 +34,7 @@ */ @Factory @Internal -class JSBeanFactory { +class ReactJSBeanFactory { private static final Logger LOG = LoggerFactory.getLogger("js"); /** @@ -53,8 +53,8 @@ HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { } @Singleton - BeanPool contextPool(ApplicationContext applicationContext) { - return new BeanPool<>(() -> applicationContext.createBean(JSContext.class)); + BeanPool contextPool(ApplicationContext applicationContext) { + return new BeanPool<>(() -> applicationContext.createBean(ReactJSContext.class)); } @Singleton diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java similarity index 91% rename from views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java rename to views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java index eef084131..b25299ffa 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java @@ -39,8 +39,8 @@ */ @Singleton @Internal -class JSBundlePaths { - private static final Logger LOG = LoggerFactory.getLogger(JSBundlePaths.class); +class ReactJSBundlePaths { + private static final Logger LOG = LoggerFactory.getLogger(ReactJSBundlePaths.class); // Source code file name, for JS stack traces. private final String bundleFileName; @@ -53,7 +53,7 @@ class JSBundlePaths { final Path bundlePath; @Inject - JSBundlePaths(ReactViewsRendererConfiguration reactConfiguration, ResourceResolver resolver) throws IOException { + ReactJSBundlePaths(ReactViewsRendererConfiguration reactConfiguration, ResourceResolver resolver) throws IOException { Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); if (bundlePathOpt.isEmpty()) { throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java similarity index 96% rename from views-react/src/main/java/io/micronaut/views/react/JSContext.java rename to views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index cb25c0327..371c80f3e 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -36,7 +36,7 @@ */ @Internal @Bean -class JSContext implements AutoCloseable { +class ReactJSContext implements AutoCloseable { // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); private final Engine engine; @@ -47,11 +47,11 @@ class JSContext implements AutoCloseable { Value render; Value ssrModule; - private final CompiledJS compiledJS; + private final CompiledReactJSBundle compiledJS; private final ReactViewsRendererConfiguration configuration; @Inject - JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, Engine engine, HostAccess hostAccess) { + ReactJSContext(CompiledReactJSBundle compiledJS, ReactViewsRendererConfiguration configuration, Engine engine, HostAccess hostAccess) { this.compiledJS = compiledJS; this.configuration = configuration; this.engine = engine; diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index bffe80817..d97cedcc5 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -45,7 +45,7 @@ public class ReactViewsRenderer implements ViewsRenderer contextPool; + BeanPool contextPool; /** * Construct this renderer. Don't call it yourself, as Micronaut Views will set it up for you. @@ -66,7 +66,7 @@ public ReactViewsRenderer() { @Override public @NonNull Writable render(@NonNull String viewName, @Nullable PROPS props, @Nullable HttpRequest request) { return writer -> { - try (BeanPool.Handle contextHandle = contextPool.checkOut()) { + try (BeanPool.Handle contextHandle = contextPool.checkOut()) { render(viewName, props, writer, contextHandle.get(), request); } catch (BeanInstantiationException e) { throw e; @@ -84,7 +84,7 @@ public boolean exists(@NonNull String viewName) { } } - private void render(String componentName, PROPS props, Writer writer, JSContext context, @Nullable HttpRequest request) { + private void render(String componentName, PROPS props, Writer writer, ReactJSContext context, @Nullable HttpRequest request) { Value component = context.ssrModule.getMember(componentName); if (component == null) { throw new IllegalArgumentException("Component name %s wasn't exported from the SSR module.".formatted(componentName)); diff --git a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy index 5f4cc2be5..881004caf 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/IntrospectableBeansAreProxiedSpec.groovy @@ -15,7 +15,7 @@ class IntrospectableBeansAreProxiedSpec extends Specification { void "introspectable bean can be proxied"() { given: - BeanPool.Handle jsContext = contextPool.checkOut() + BeanPool.Handle jsContext = contextPool.checkOut() def context = jsContext.get().polyglotContext def bean = new SomeBean("foo value", "bar value", new SomeBean.InnerBean(10, Map.of("key", 123), List.of("one", "two", "three"))) From 7e352f4cbbeb63e8f040e29012cfbefee8be7e24 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 15:50:53 +0200 Subject: [PATCH 16/31] ReactJS: Refactor more code into the bean factory. --- .../views/react/CompiledReactJSBundle.java | 23 +++--- .../views/react/ReactJSBeanFactory.java | 28 +++++++ .../views/react/ReactJSBundlePaths.java | 78 ------------------- 3 files changed, 38 insertions(+), 91 deletions(-) delete mode 100644 views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java index 52581a7e8..7438a3ea2 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java @@ -15,6 +15,7 @@ */ package io.micronaut.views.react; +import io.micronaut.context.ApplicationContext; import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; import io.micronaut.scheduling.io.watch.event.FileChangedEvent; @@ -28,7 +29,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; +import java.nio.file.Paths; /** * Holds the thread-safe {@link Engine} and {@link Source} which together pin compiled machine code @@ -41,14 +42,14 @@ class CompiledReactJSBundle implements AutoCloseable, ApplicationEventListener beanPool; + private final ApplicationContext applicationContext; private Source source; - private final ReactJSBundlePaths jsBundlePaths; @Inject - CompiledReactJSBundle(ReactJSBundlePaths jsBundlePaths, Engine engine, BeanPool beanPool) { - this.jsBundlePaths = jsBundlePaths; + CompiledReactJSBundle(Engine engine, BeanPool beanPool, ApplicationContext applicationContext) { this.engine = engine; this.beanPool = beanPool; + this.applicationContext = applicationContext; reload(); } @@ -56,14 +57,6 @@ synchronized Source getSource() { return source; } - private synchronized void reload() { - try { - source = jsBundlePaths.readServerBundle(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - @Override @PreDestroy public void close() throws Exception { @@ -72,10 +65,14 @@ public void close() throws Exception { @Override public void onApplicationEvent(FileChangedEvent event) { - if (jsBundlePaths.bundlePath != null && event.getPath().equals(jsBundlePaths.bundlePath) && event.getEventType() != WatchEventType.DELETE) { + if (event.getPath().equals(Paths.get(source.getPath())) && event.getEventType() != WatchEventType.DELETE) { LOG.info("Reloading Javascript bundle due to file change."); reload(); beanPool.clear(); } } + + private synchronized void reload() { + source = applicationContext.createBean(Source.class); + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 0e6f5d528..53c6e9c84 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -16,8 +16,10 @@ package io.micronaut.views.react; import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; +import io.micronaut.core.io.ResourceResolver; import io.micronaut.views.react.util.BeanPool; import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; import io.micronaut.views.react.util.OutputStreamToSLF4J; @@ -25,10 +27,20 @@ import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.SandboxPolicy; +import org.graalvm.polyglot.Source; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Optional; + +import static java.lang.String.format; + /** * Allows the default Javascript context and host access policy to be controlled. */ @@ -68,4 +80,20 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED) .build(); } + + @Bean + Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException, URISyntaxException { + Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); + if (bundlePathOpt.isEmpty()) { + throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + } + var bundleURL = bundlePathOpt.get(); + Source.Builder sourceBuilder; + if (bundleURL.getProtocol().equals("file")) { + sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); + } else { + sourceBuilder = Source.newBuilder("js", bundleURL); + } + return sourceBuilder.mimeType("application/javascript+module").build(); + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java deleted file mode 100644 index b25299ffa..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBundlePaths.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.micronaut.views.react; - -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.io.ResourceResolver; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import org.graalvm.polyglot.Source; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedReader; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.URL; -import java.nio.file.Path; -import java.util.Optional; - -import static java.lang.String.format; - -/** - * Wraps the computation of where to find the JS for client and server. - */ -@Singleton -@Internal -class ReactJSBundlePaths { - private static final Logger LOG = LoggerFactory.getLogger(ReactJSBundlePaths.class); - - // Source code file name, for JS stack traces. - private final String bundleFileName; - - // URL of bundle file, could be a file:// or in a classpath jar. - private final URL bundleURL; - - // If a file:// (during development), the path of that file. Used for hot reloads. - @Nullable - final Path bundlePath; - - @Inject - ReactJSBundlePaths(ReactViewsRendererConfiguration reactConfiguration, ResourceResolver resolver) throws IOException { - Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); - if (bundlePathOpt.isEmpty()) { - throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); - } - bundleURL = bundlePathOpt.get(); - bundleFileName = bundleURL.getFile(); - if (bundleURL.getProtocol().equals("file")) { - bundlePath = Path.of(bundleURL.getPath()); - LOG.info("Using server-side JS bundle from local disk: {}", bundlePath); - } else { - bundlePath = null; - } - } - - Source readServerBundle() throws IOException { - try (var reader = new BufferedReader(new InputStreamReader(bundleURL.openStream()))) { - return Source.newBuilder("js", reader, bundleFileName) - .mimeType("application/javascript+module") - .build(); - } - } -} From 760344a23ebaed08afe798b0776ce294803b6bed Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:08:14 +0200 Subject: [PATCH 17/31] ReactJS: Use qualifiers to avoid conflicts with other Micronaut code that uses Truffle classes. --- .../views/react/CompiledReactJSBundle.java | 21 +++++++++++++++++-- .../views/react/ReactJSBeanFactory.java | 15 +++++++------ .../micronaut/views/react/ReactJSContext.java | 3 ++- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java index 7438a3ea2..6ca8d12e5 100644 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java @@ -16,13 +16,16 @@ package io.micronaut.views.react; import io.micronaut.context.ApplicationContext; +import io.micronaut.context.Qualifier; import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; +import io.micronaut.inject.BeanType; import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.react.util.BeanPool; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.inject.Singleton; import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.Source; @@ -30,6 +33,7 @@ import org.slf4j.LoggerFactory; import java.nio.file.Paths; +import java.util.stream.Stream; /** * Holds the thread-safe {@link Engine} and {@link Source} which together pin compiled machine code @@ -46,7 +50,7 @@ class CompiledReactJSBundle implements AutoCloseable, ApplicationEventListener beanPool, ApplicationContext applicationContext) { + CompiledReactJSBundle(@Named("react") Engine engine, BeanPool beanPool, ApplicationContext applicationContext) { this.engine = engine; this.beanPool = beanPool; this.applicationContext = applicationContext; @@ -72,7 +76,20 @@ public void onApplicationEvent(FileChangedEvent event) { } } + private static class ReactSourceQualifier implements Qualifier { + @Override + public > Stream reduce(Class beanType, Stream candidates) { + return candidates.filter(bt -> { + var n = bt.getBeanName(); + return n.isPresent() && n.get().equals("react"); + }); + } + + static ReactSourceQualifier INSTANCE = new ReactSourceQualifier(); + } + private synchronized void reload() { - source = applicationContext.createBean(Source.class); + // This ensures we ignore other Source objects that aren't marked as being for us. + source = applicationContext.createBean(Source.class, ReactSourceQualifier.INSTANCE); } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 53c6e9c84..ff4a39702 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -23,6 +23,7 @@ import io.micronaut.views.react.util.BeanPool; import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; import io.micronaut.views.react.util.OutputStreamToSLF4J; +import jakarta.inject.Named; import jakarta.inject.Singleton; import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; @@ -50,14 +51,14 @@ class ReactJSBeanFactory { private static final Logger LOG = LoggerFactory.getLogger("js"); /** - * This defaults to - * {@link HostAccess#ALL} if the sandbox is disabled, or {@link HostAccess#CONSTRAINED} if it's on. - * By replacing the {@link HostAccess} bean you can whitelist methods/properties by name or - * annotation, which can be useful for exposing third party libraries where you can't add the - * normal {@link HostAccess.Export} annotation, or allowing sandboxed JS to extend or implement - * Java types. + * This defaults to {@link HostAccess#ALL} if the sandbox is disabled, or {@link + * HostAccess#CONSTRAINED} if it's on. By replacing the {@link HostAccess} bean you can + * whitelist methods/properties by name or annotation, which can be useful for exposing third + * party libraries where you can't add the normal {@link HostAccess.Export} annotation, or + * allowing sandboxed JS to extend or implement Java types. */ @Singleton + @Named("react") HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { return configuration.getSandbox() ? HostAccess.newBuilder(HostAccess.CONSTRAINED).allowListAccess(true).allowMapAccess(true).build() @@ -70,6 +71,7 @@ BeanPool contextPool(ApplicationContext applicationContext) { } @Singleton + @Named("react") Engine engine(ReactViewsRendererConfiguration configuration) { boolean sandbox = configuration.getSandbox(); LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); @@ -82,6 +84,7 @@ Engine engine(ReactViewsRendererConfiguration configuration) { } @Bean + @Named("react") Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException, URISyntaxException { Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); if (bundlePathOpt.isEmpty()) { diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index 371c80f3e..e0a0aa29b 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -21,6 +21,7 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; +import jakarta.inject.Named; import org.graalvm.polyglot.*; import java.io.IOException; @@ -51,7 +52,7 @@ class ReactJSContext implements AutoCloseable { private final ReactViewsRendererConfiguration configuration; @Inject - ReactJSContext(CompiledReactJSBundle compiledJS, ReactViewsRendererConfiguration configuration, Engine engine, HostAccess hostAccess) { + ReactJSContext(CompiledReactJSBundle compiledJS, ReactViewsRendererConfiguration configuration, @Named("react") Engine engine, @Named("react") HostAccess hostAccess) { this.compiledJS = compiledJS; this.configuration = configuration; this.engine = engine; From db58728811cdcbe8902f95b28f217baf30b2e2ae Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:29:47 +0200 Subject: [PATCH 18/31] ReactJS: Refactor away the CompiledReactJSBundle class. It's no longer really needed. --- .../views/react/CompiledReactJSBundle.java | 95 ------------------- .../views/react/ReactJSBeanFactory.java | 39 +++++--- .../micronaut/views/react/ReactJSContext.java | 25 ++++- .../views/react/ReactViewsRenderer.java | 24 ++++- 4 files changed, 69 insertions(+), 114 deletions(-) delete mode 100644 views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java b/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java deleted file mode 100644 index 6ca8d12e5..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledReactJSBundle.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.micronaut.views.react; - -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.Qualifier; -import io.micronaut.context.event.ApplicationEventListener; -import io.micronaut.core.annotation.Internal; -import io.micronaut.inject.BeanType; -import io.micronaut.scheduling.io.watch.event.FileChangedEvent; -import io.micronaut.scheduling.io.watch.event.WatchEventType; -import io.micronaut.views.react.util.BeanPool; -import jakarta.annotation.PreDestroy; -import jakarta.inject.Inject; -import jakarta.inject.Named; -import jakarta.inject.Singleton; -import org.graalvm.polyglot.Engine; -import org.graalvm.polyglot.Source; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Paths; -import java.util.stream.Stream; - -/** - * Holds the thread-safe {@link Engine} and {@link Source} which together pin compiled machine code - * into the JVM code cache. - */ -@Singleton -@Internal -class CompiledReactJSBundle implements AutoCloseable, ApplicationEventListener { - private static final Logger LOG = LoggerFactory.getLogger("js"); - - private final Engine engine; - private final BeanPool beanPool; - private final ApplicationContext applicationContext; - private Source source; - - @Inject - CompiledReactJSBundle(@Named("react") Engine engine, BeanPool beanPool, ApplicationContext applicationContext) { - this.engine = engine; - this.beanPool = beanPool; - this.applicationContext = applicationContext; - reload(); - } - - synchronized Source getSource() { - return source; - } - - @Override - @PreDestroy - public void close() throws Exception { - engine.close(); - } - - @Override - public void onApplicationEvent(FileChangedEvent event) { - if (event.getPath().equals(Paths.get(source.getPath())) && event.getEventType() != WatchEventType.DELETE) { - LOG.info("Reloading Javascript bundle due to file change."); - reload(); - beanPool.clear(); - } - } - - private static class ReactSourceQualifier implements Qualifier { - @Override - public > Stream reduce(Class beanType, Stream candidates) { - return candidates.filter(bt -> { - var n = bt.getBeanName(); - return n.isPresent() && n.get().equals("react"); - }); - } - - static ReactSourceQualifier INSTANCE = new ReactSourceQualifier(); - } - - private synchronized void reload() { - // This ensures we ignore other Source objects that aren't marked as being for us. - source = applicationContext.createBean(Source.class, ReactSourceQualifier.INSTANCE); - } -} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index ff4a39702..5c1ee1633 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -38,6 +38,8 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Optional; import static java.lang.String.format; @@ -83,20 +85,35 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .build(); } + private Source serverBundle; // L(this) + @Bean @Named("react") - Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException, URISyntaxException { - Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); - if (bundlePathOpt.isEmpty()) { - throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException, URISyntaxException { + // We cache the Source object because it's expensive to create, but, we don't want it to be a singleton + // so we can recreate it. + if (serverBundle == null) { + Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); + if (bundlePathOpt.isEmpty()) { + throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + } + var bundleURL = bundlePathOpt.get(); + Source.Builder sourceBuilder; + if (bundleURL.getProtocol().equals("file")) { + sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); + } else { + sourceBuilder = Source.newBuilder("js", bundleURL); + } + serverBundle = sourceBuilder.mimeType("application/javascript+module").build(); } - var bundleURL = bundlePathOpt.get(); - Source.Builder sourceBuilder; - if (bundleURL.getProtocol().equals("file")) { - sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); - } else { - sourceBuilder = Source.newBuilder("js", bundleURL); + return serverBundle; + } + + synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { + if (serverBundle != null && fileThatChanged.toAbsolutePath().equals(Paths.get(serverBundle.getPath()).toAbsolutePath())) { + serverBundle = null; + return true; } - return sourceBuilder.mimeType("application/javascript+module").build(); + return false; } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index e0a0aa29b..7d4f2c43c 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -15,9 +15,12 @@ */ package io.micronaut.views.react; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.Qualifier; import io.micronaut.context.annotation.Bean; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.inject.BeanType; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; @@ -28,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import static java.nio.charset.StandardCharsets.UTF_8; @@ -42,21 +46,33 @@ class ReactJSContext implements AutoCloseable { private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); private final Engine engine; private final HostAccess hostAccess; + private final ApplicationContext applicationContext; // Accessed from ReactViewsRenderer Context polyglotContext; Value render; Value ssrModule; - private final CompiledReactJSBundle compiledJS; private final ReactViewsRendererConfiguration configuration; @Inject - ReactJSContext(CompiledReactJSBundle compiledJS, ReactViewsRendererConfiguration configuration, @Named("react") Engine engine, @Named("react") HostAccess hostAccess) { - this.compiledJS = compiledJS; + ReactJSContext(ReactViewsRendererConfiguration configuration, + @Named("react") Engine engine, + @Named("react") HostAccess hostAccess, + ApplicationContext applicationContext) { this.configuration = configuration; this.engine = engine; this.hostAccess = hostAccess; + this.applicationContext = applicationContext; + } + + private static class ReactSourceQualifier implements Qualifier { + @Override + public > Stream reduce(Class beanType, Stream candidates) { + return candidates.filter(bt -> "react".equals(bt.getBeanName().orElse(null))); + } + + static ReactSourceQualifier INSTANCE = new ReactSourceQualifier(); } @PostConstruct @@ -64,7 +80,8 @@ void init() throws IOException { polyglotContext = createContext(); Value global = polyglotContext.getBindings("js"); - ssrModule = polyglotContext.eval(compiledJS.getSource()); + // This will return a cached, shared Source object. + ssrModule = polyglotContext.eval(applicationContext.createBean(Source.class, ReactSourceQualifier.INSTANCE)); // Take all the exports from the components bundle, and expose them to the render script. for (var name : ssrModule.getMemberKeys()) { diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index d97cedcc5..5a9b15915 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -15,12 +15,15 @@ */ package io.micronaut.views.react; +import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.context.exceptions.BeanInstantiationException; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.Writable; import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; +import io.micronaut.scheduling.io.watch.event.FileChangedEvent; +import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.ViewsRenderer; import io.micronaut.views.react.truffle.IntrospectableToTruffleAdapter; import io.micronaut.views.react.util.BeanPool; @@ -28,6 +31,8 @@ import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Writer; @@ -40,12 +45,15 @@ * @param An introspectable bean type that will be fed to the ReactJS root component as props. */ @Singleton -public class ReactViewsRenderer implements ViewsRenderer> { +public class ReactViewsRenderer implements ViewsRenderer>, ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(ReactViewsRenderer.class); @Inject ReactViewsRendererConfiguration reactConfiguration; @Inject - BeanPool contextPool; + BeanPool beanPool; + @Inject + private ReactJSBeanFactory reactJSBeanFactory; /** * Construct this renderer. Don't call it yourself, as Micronaut Views will set it up for you. @@ -66,7 +74,7 @@ public ReactViewsRenderer() { @Override public @NonNull Writable render(@NonNull String viewName, @Nullable PROPS props, @Nullable HttpRequest request) { return writer -> { - try (BeanPool.Handle contextHandle = contextPool.checkOut()) { + try (BeanPool.Handle contextHandle = beanPool.checkOut()) { render(viewName, props, writer, contextHandle.get(), request); } catch (BeanInstantiationException e) { throw e; @@ -79,7 +87,7 @@ public ReactViewsRenderer() { @Override public boolean exists(@NonNull String viewName) { - try (var contextHandle = contextPool.checkOut()) { + try (var contextHandle = beanPool.checkOut()) { return contextHandle.get().moduleHasMember(viewName); } } @@ -148,4 +156,12 @@ public void write(int[] unsignedBytes) { } } } + + @Override + public void onApplicationEvent(FileChangedEvent event) { + if (event.getEventType() != WatchEventType.DELETE && reactJSBeanFactory.maybeReloadServerBundle(event.getPath())) { + beanPool.clear(); + LOG.info("Reloaded React SSR bundle due to file change."); + } + } } From 0a12180d502a85f3ab0a3b6e50bec467fc056788 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:31:06 +0200 Subject: [PATCH 19/31] ReactJS: Use c'tor injection. --- .../views/react/ReactViewsRenderer.java | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 5a9b15915..233829546 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -27,7 +27,6 @@ import io.micronaut.views.ViewsRenderer; import io.micronaut.views.react.truffle.IntrospectableToTruffleAdapter; import io.micronaut.views.react.util.BeanPool; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.Value; @@ -45,21 +44,17 @@ * @param An introspectable bean type that will be fed to the ReactJS root component as props. */ @Singleton -public class ReactViewsRenderer implements ViewsRenderer>, ApplicationEventListener { +class ReactViewsRenderer implements ViewsRenderer>, ApplicationEventListener { private static final Logger LOG = LoggerFactory.getLogger(ReactViewsRenderer.class); - @Inject - ReactViewsRendererConfiguration reactConfiguration; - @Inject - BeanPool beanPool; - @Inject - private ReactJSBeanFactory reactJSBeanFactory; + private final BeanPool beanPool; + private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; + private final ReactJSBeanFactory reactJSBeanFactory; - /** - * Construct this renderer. Don't call it yourself, as Micronaut Views will set it up for you. - */ - @Inject - public ReactViewsRenderer() { + ReactViewsRenderer(BeanPool beanPool, ReactViewsRendererConfiguration reactViewsRendererConfiguration, ReactJSBeanFactory reactJSBeanFactory) { + this.beanPool = beanPool; + this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; + this.reactJSBeanFactory = reactJSBeanFactory; } /** @@ -104,7 +99,7 @@ private void render(String componentName, PROPS props, Writer writer, ReactJSCon // This should be more native-image friendly (no need to write reflection config files), and // might also be faster. Value guestProps = IntrospectableToTruffleAdapter.wrap(context.polyglotContext, props); - context.render.executeVoid(component, guestProps, renderCallback, reactConfiguration.getClientBundleURL(), request); + context.render.executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); } /** From a7e057b0a3fda2303b77cea4bd5d3a490fe2a2ef Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:33:48 +0200 Subject: [PATCH 20/31] ReactJS: Extract source builder method. --- .../views/react/ReactJSBeanFactory.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 5c1ee1633..734e12493 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -94,21 +94,25 @@ synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererCo // so we can recreate it. if (serverBundle == null) { Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); - if (bundlePathOpt.isEmpty()) { - throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); - } - var bundleURL = bundlePathOpt.get(); - Source.Builder sourceBuilder; - if (bundleURL.getProtocol().equals("file")) { - sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); - } else { - sourceBuilder = Source.newBuilder("js", bundleURL); - } - serverBundle = sourceBuilder.mimeType("application/javascript+module").build(); + serverBundle = serverBundleSourceBuilder(reactConfiguration, bundlePathOpt).build(); } return serverBundle; } + private static Source.Builder serverBundleSourceBuilder(ReactViewsRendererConfiguration config, Optional bundlePathOpt) throws FileNotFoundException, URISyntaxException { + if (bundlePathOpt.isEmpty()) { + throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", config.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + } + URL bundleURL = bundlePathOpt.get(); + Source.Builder sourceBuilder; + if (bundleURL.getProtocol().equals("file")) { + sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); + } else { + sourceBuilder = Source.newBuilder("js", bundleURL); + } + return sourceBuilder.mimeType("application/javascript+module"); + } + synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { if (serverBundle != null && fileThatChanged.toAbsolutePath().equals(Paths.get(serverBundle.getPath()).toAbsolutePath())) { serverBundle = null; From c6f7037c9c12467d93b29681e786ddb08be57726 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:37:05 +0200 Subject: [PATCH 21/31] ReactJS: Simplify source builder logic. --- .../views/react/ReactJSBeanFactory.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 734e12493..a09e28e73 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -33,10 +33,8 @@ import org.slf4j.LoggerFactory; import org.slf4j.event.Level; -import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; @@ -89,7 +87,7 @@ Engine engine(ReactViewsRendererConfiguration configuration) { @Bean @Named("react") - synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException, URISyntaxException { + synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException { // We cache the Source object because it's expensive to create, but, we don't want it to be a singleton // so we can recreate it. if (serverBundle == null) { @@ -99,17 +97,11 @@ synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererCo return serverBundle; } - private static Source.Builder serverBundleSourceBuilder(ReactViewsRendererConfiguration config, Optional bundlePathOpt) throws FileNotFoundException, URISyntaxException { - if (bundlePathOpt.isEmpty()) { + private static Source.Builder serverBundleSourceBuilder(ReactViewsRendererConfiguration config, Optional bundleURLOpt) throws FileNotFoundException { + if (bundleURLOpt.isEmpty()) { throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", config.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); } - URL bundleURL = bundlePathOpt.get(); - Source.Builder sourceBuilder; - if (bundleURL.getProtocol().equals("file")) { - sourceBuilder = Source.newBuilder("js", new File(bundleURL.toURI())); - } else { - sourceBuilder = Source.newBuilder("js", bundleURL); - } + Source.Builder sourceBuilder = Source.newBuilder("js", bundleURLOpt.get()); return sourceBuilder.mimeType("application/javascript+module"); } From 634315e938827a84206ffb3a8a9dfa87e01f2aaa Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 16:50:39 +0200 Subject: [PATCH 22/31] ReactJS: Unify more source builder logic and simplify again. --- .../views/react/ReactJSBeanFactory.java | 32 +++++++--- .../micronaut/views/react/ReactJSContext.java | 62 +++---------------- .../ReactViewsRendererConfiguration.java | 2 +- .../views/react/PreactViewRenderSpec.groovy | 2 +- 4 files changed, 35 insertions(+), 63 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index a09e28e73..3efcf3d1f 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -83,26 +83,36 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .build(); } + // We cache the Source objects because they are expensive to create, but, we don't want them + // to be singleton beans so we can recreate them on file change. private Source serverBundle; // L(this) + private Source renderScript; // L(this) @Bean @Named("react") synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException { - // We cache the Source object because it's expensive to create, but, we don't want it to be a singleton - // so we can recreate it. if (serverBundle == null) { - Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); - serverBundle = serverBundleSourceBuilder(reactConfiguration, bundlePathOpt).build(); + serverBundle = loadSource(resolver, reactConfiguration.getServerBundlePath(), ".server-bundle-path"); } return serverBundle; } - private static Source.Builder serverBundleSourceBuilder(ReactViewsRendererConfiguration config, Optional bundleURLOpt) throws FileNotFoundException { - if (bundleURLOpt.isEmpty()) { - throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", config.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + @Bean + @Named("react-render-script") + synchronized Source renderScript(ResourceResolver resolver, ReactViewsRendererConfiguration config) throws IOException { + if (renderScript == null) { + renderScript = loadSource(resolver, config.getRenderScript(), ".render-script"); + } + return renderScript; + } + + private static Source loadSource(ResourceResolver resolver, String desiredPath, String propName) throws IOException { + Optional sourceURL = resolver.getResource(desiredPath); + if (sourceURL.isEmpty()) { + throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); } - Source.Builder sourceBuilder = Source.newBuilder("js", bundleURLOpt.get()); - return sourceBuilder.mimeType("application/javascript+module"); + Source.Builder sourceBuilder = Source.newBuilder("js", sourceURL.get()); + return sourceBuilder.mimeType("application/javascript+module").build(); } synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { @@ -110,6 +120,10 @@ synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { serverBundle = null; return true; } + if (renderScript != null && fileThatChanged.toAbsolutePath().equals(Paths.get(renderScript.getPath()).toAbsolutePath())) { + renderScript = null; + return true; + } return false; } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index 7d4f2c43c..388034e58 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -19,7 +19,6 @@ import io.micronaut.context.Qualifier; import io.micronaut.context.annotation.Bean; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.annotation.NonNull; import io.micronaut.inject.BeanType; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -27,14 +26,9 @@ import jakarta.inject.Named; import org.graalvm.polyglot.*; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.stream.Stream; -import static java.nio.charset.StandardCharsets.UTF_8; - /** * A bean that handles the Javascript {@link Context} object representing a loaded execution * environment usable by one thread at a time. @@ -66,22 +60,22 @@ class ReactJSContext implements AutoCloseable { this.applicationContext = applicationContext; } - private static class ReactSourceQualifier implements Qualifier { + private record NamedSourceQualifier(String name) implements Qualifier { @Override public > Stream reduce(Class beanType, Stream candidates) { - return candidates.filter(bt -> "react".equals(bt.getBeanName().orElse(null))); + return candidates.filter(bt -> name.equals(bt.getBeanName().orElse(null))); } - static ReactSourceQualifier INSTANCE = new ReactSourceQualifier(); + static NamedSourceQualifier SSR = new NamedSourceQualifier("react"); + static NamedSourceQualifier RENDER_SCRIPT = new NamedSourceQualifier("react-render-script"); } @PostConstruct - void init() throws IOException { + void init() { polyglotContext = createContext(); Value global = polyglotContext.getBindings("js"); - // This will return a cached, shared Source object. - ssrModule = polyglotContext.eval(applicationContext.createBean(Source.class, ReactSourceQualifier.INSTANCE)); + ssrModule = loadNamedModule(NamedSourceQualifier.SSR); // Take all the exports from the components bundle, and expose them to the render script. for (var name : ssrModule.getMemberKeys()) { @@ -89,52 +83,16 @@ void init() throws IOException { } // Evaluate our JS-side framework specific render logic. - Source source = loadRenderSource(); - Value renderModule = polyglotContext.eval(source); + Value renderModule = loadNamedModule(NamedSourceQualifier.RENDER_SCRIPT); render = renderModule.getMember("ssr"); if (render == null) { throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); } } - private Source loadRenderSource() throws IOException { - String renderScriptName = configuration.getRenderScript(); - String fileName; - String source; - - if (renderScriptName.startsWith("classpath:")) { - var resourcePath = renderScriptName.substring("classpath:".length()); - // Even on Windows, classpath specs use / - fileName = fileNameFromUNIXPath(resourcePath); - try (var stream = getClass().getResourceAsStream(resourcePath)) { - if (stream == null) { - throw new IllegalArgumentException("Render script not found on classpath: " + resourcePath); - } - source = new String(stream.readAllBytes(), UTF_8); - } - } else if (renderScriptName.startsWith("file:")) { - var path = Path.of(renderScriptName.substring("file:".length())); - if (!Files.exists(path)) { - throw new IllegalArgumentException("Render script not found: " + renderScriptName); - } - fileName = path.normalize().toAbsolutePath().getFileName().toString(); - try (var stream = Files.newInputStream(path)) { - source = new String(stream.readAllBytes(), UTF_8); - } - } else { - throw new IllegalArgumentException("The renderScript name '%s' must begin with either `classpath:` or `file:`".formatted(renderScriptName)); - } - - return Source.newBuilder("js", source, fileName) - .mimeType("application/javascript+module") - .build(); - } - - private static @NonNull String fileNameFromUNIXPath(String resourcePath) { - String fileName; - var i = resourcePath.lastIndexOf('/'); - fileName = resourcePath.substring(i + 1); - return fileName; + private Value loadNamedModule(NamedSourceQualifier sourceQualifier) { + // This will return a cached, shared Source object. + return polyglotContext.eval(applicationContext.createBean(Source.class, sourceQualifier)); } private Context createContext() { diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java index 2eb0fd745..539b59022 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java @@ -38,7 +38,7 @@ public interface ReactViewsRendererConfiguration { String DEFAULT_SERVER_BUNDLE_PATH = "classpath:views/ssr-components.mjs"; /** The default value for {@link #getRenderScript()}. */ - String DEFAULT_RENDER_SCRIPT = "classpath:/io/micronaut/views/react/react.js"; + String DEFAULT_RENDER_SCRIPT = "classpath:io/micronaut/views/react/react.js"; /** * @return the URL (relative or absolute) where the client Javascript bundle can be found. It will diff --git a/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy index 6b4e40432..b79fc6bad 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy @@ -14,7 +14,7 @@ import spock.lang.Specification @MicronautTest(startApplication = false) @Property(name = "micronaut.views.react.client-bundle-url", value = "/static/client.preact.js") @Property(name = "micronaut.views.react.server-bundle-path", value = "classpath:views/ssr-components.preact.mjs") -@Property(name = "micronaut.views.react.render-script", value = "classpath:/io/micronaut/views/react/preact.js") +@Property(name = "micronaut.views.react.render-script", value = "classpath:io/micronaut/views/react/preact.js") class PreactViewRenderSpec extends Specification { @Inject ReactViewsRenderer renderer From 9fd6eafb76d664fb91ded1317bfc69f135618aa1 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 12 Aug 2024 17:02:18 +0200 Subject: [PATCH 23/31] ReactJS: checkstyle fixes --- .../views/react/ReactJSBeanFactory.java | 10 +-- .../micronaut/views/react/ReactJSContext.java | 30 +++---- .../views/react/ReactViewsRenderer.java | 16 ++-- .../IntrospectableToTruffleAdapter.java | 6 +- .../micronaut/views/react/util/BeanPool.java | 83 ++++++++++--------- .../views/react/util/OutputStreamToSLF4J.java | 8 ++ 6 files changed, 83 insertions(+), 70 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 3efcf3d1f..ae0f1eeda 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -50,6 +50,11 @@ class ReactJSBeanFactory { private static final Logger LOG = LoggerFactory.getLogger("js"); + // We cache the Source objects because they are expensive to create, but, we don't want them + // to be singleton beans so we can recreate them on file change. + private Source serverBundle; // L(this) + private Source renderScript; // L(this) + /** * This defaults to {@link HostAccess#ALL} if the sandbox is disabled, or {@link * HostAccess#CONSTRAINED} if it's on. By replacing the {@link HostAccess} bean you can @@ -83,11 +88,6 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .build(); } - // We cache the Source objects because they are expensive to create, but, we don't want them - // to be singleton beans so we can recreate them on file change. - private Source serverBundle; // L(this) - private Source renderScript; // L(this) - @Bean @Named("react") synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException { diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index 388034e58..c8861ed29 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -38,15 +38,15 @@ class ReactJSContext implements AutoCloseable { // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); - private final Engine engine; - private final HostAccess hostAccess; - private final ApplicationContext applicationContext; // Accessed from ReactViewsRenderer Context polyglotContext; Value render; Value ssrModule; + private final Engine engine; + private final HostAccess hostAccess; + private final ApplicationContext applicationContext; private final ReactViewsRendererConfiguration configuration; @Inject @@ -60,22 +60,12 @@ class ReactJSContext implements AutoCloseable { this.applicationContext = applicationContext; } - private record NamedSourceQualifier(String name) implements Qualifier { - @Override - public > Stream reduce(Class beanType, Stream candidates) { - return candidates.filter(bt -> name.equals(bt.getBeanName().orElse(null))); - } - - static NamedSourceQualifier SSR = new NamedSourceQualifier("react"); - static NamedSourceQualifier RENDER_SCRIPT = new NamedSourceQualifier("react-render-script"); - } - @PostConstruct void init() { polyglotContext = createContext(); Value global = polyglotContext.getBindings("js"); - ssrModule = loadNamedModule(NamedSourceQualifier.SSR); + ssrModule = loadNamedModule(NamedSourceQualifier.ssr); // Take all the exports from the components bundle, and expose them to the render script. for (var name : ssrModule.getMemberKeys()) { @@ -83,7 +73,7 @@ void init() { } // Evaluate our JS-side framework specific render logic. - Value renderModule = loadNamedModule(NamedSourceQualifier.RENDER_SCRIPT); + Value renderModule = loadNamedModule(NamedSourceQualifier.renderScript); render = renderModule.getMember("ssr"); if (render == null) { throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); @@ -144,4 +134,14 @@ boolean moduleHasMember(String memberName) { public synchronized void close() { polyglotContext.close(); } + + private record NamedSourceQualifier(String name) implements Qualifier { + static NamedSourceQualifier ssr = new NamedSourceQualifier("react"); + static NamedSourceQualifier renderScript = new NamedSourceQualifier("react-render-script"); + + @Override + public > Stream reduce(Class beanType, Stream candidates) { + return candidates.filter(bt -> name.equals(bt.getBeanName().orElse(null))); + } + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 233829546..57c693543 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -102,6 +102,14 @@ private void render(String componentName, PROPS props, Writer writer, ReactJSCon context.render.executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); } + @Override + public void onApplicationEvent(FileChangedEvent event) { + if (event.getEventType() != WatchEventType.DELETE && reactJSBeanFactory.maybeReloadServerBundle(event.getPath())) { + beanPool.clear(); + LOG.info("Reloaded React SSR bundle due to file change."); + } + } + /** * Methods exposed to the ReactJS components and render scripts. Needs to be public to be * callable from the JS side. @@ -151,12 +159,4 @@ public void write(int[] unsignedBytes) { } } } - - @Override - public void onApplicationEvent(FileChangedEvent event) { - if (event.getEventType() != WatchEventType.DELETE && reactJSBeanFactory.maybeReloadServerBundle(event.getPath())) { - beanPool.clear(); - LOG.info("Reloaded React SSR bundle due to file change."); - } - } } diff --git a/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java b/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java index cb858b7f9..6d035cc5e 100644 --- a/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java +++ b/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java @@ -53,7 +53,11 @@ private IntrospectableToTruffleAdapter(Context context, Object target, BeanIntro } /** - * Returns an object as a Truffle {@link Value} suitable for guest access, wrapping introspectable types with {@link IntrospectableToTruffleAdapter}. + * Wraps an object as a Truffle {@link Value} suitable for guest access, wrapping introspectable types with {@link IntrospectableToTruffleAdapter}. + * + * @param context The language context to wrap the object into. + * @param object Either null, a {@link Map}, a {@link Collection}, an {@link io.micronaut.core.annotation.Introspected introspectable object}, or any other object supported by the Polyglot interop layer. + * @return A value that will return true to {@link Value#isProxyObject()} */ public static Value wrap(Context context, Object object) { if (object == null) { diff --git a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java index b9dc4076e..b1616cd23 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -16,8 +16,6 @@ package io.micronaut.views.react.util; import io.micronaut.core.annotation.Internal; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.IOException; @@ -48,52 +46,15 @@ * a sudden spike of traffic that drives many checkouts, memory usage may grow significantly * and not be released. Fixing this would be a good future improvement to the pool. *

+ * + * @param The type of the bean that being pooled. */ @Internal public class BeanPool { // TODO: Use @Scheduled to occasionally clear out beans that weren't accessed for a while to recover from traffic spikes. - private static final Logger LOG = LoggerFactory.getLogger(BeanPool.class); - private final Supplier factory; - /** - * A handle to a pooled object. Call {@link #get()} to obtain the wrapped reference, and then - * pass this handle to {@link BeanPool#checkIn(Handle)} to put it back. Alternatively you can - * just close this object to check it back in. - */ - public interface Handle extends Supplier, AutoCloseable { - @Override - void close(); - } - - private final class PoolEntry implements Handle { - final T obj; - final int version; - - private PoolEntry(T obj, int version) { - this.obj = obj; - this.version = version; - } - - @Override - public T get() { - return obj; - } - - @Override - public void close() { - checkIn(this); - } - - @Override - public String toString() { - return "PoolEntry[" + - "obj=" + obj + ", " + - "version=" + version + ']'; - } - } - // Synchronized on 'this'. private final LinkedList> pool = new LinkedList<>(); private int versionCounter = 0; // File reloads. @@ -176,4 +137,44 @@ public synchronized void clear() { } pool.clear(); } + + + /** + * A handle to a pooled object. Call {@link #get()} to obtain the wrapped reference, and then + * pass this handle to {@link BeanPool#checkIn(Handle)} to put it back. Alternatively you can + * just close this object to check it back in. + * + * @param The type of the object being referenced. + */ + public interface Handle extends Supplier, AutoCloseable { + @Override + void close(); + } + + private final class PoolEntry implements Handle { + final T obj; + final int version; + + private PoolEntry(T obj, int version) { + this.obj = obj; + this.version = version; + } + + @Override + public T get() { + return obj; + } + + @Override + public void close() { + checkIn(this); + } + + @Override + public String toString() { + return "PoolEntry[" + + "obj=" + obj + ", " + + "version=" + version + ']'; + } + } } diff --git a/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java b/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java index 1f4101afa..0928dd473 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/OutputStreamToSLF4J.java @@ -39,6 +39,8 @@ public final class OutputStreamToSLF4J extends OutputStream { /** * Creates a logging stream with the JVM's default character set. + * + * @param loggingEventBuilder Lets you customize how the log events are sent to SLF4J (e.g. level, logger). */ public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder) { this(loggingEventBuilder, Charset.defaultCharset()); @@ -46,6 +48,9 @@ public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder) { /** * Creates a logging stream with the given character set. + * + * @param loggingEventBuilder Lets you customize how the log events are sent to SLF4J (e.g. level, logger). + * @param charset Encoding of strings being written to the output stream. */ public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder, Charset charset) { this.loggingEventBuilder = loggingEventBuilder; @@ -54,6 +59,9 @@ public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder, Charset char /** * Creates a logging stream for the given logger and logging level. + * + * @param logger The SLF4J logger object that the stream should emit to. + * @param level What severity to log lines at. */ public OutputStreamToSLF4J(Logger logger, Level level) { this(logger.makeLoggingEventBuilder(level)); From 3ab630d113519f0127cbfc0d898e08ffcebf885d Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Thu, 15 Aug 2024 12:17:46 +0200 Subject: [PATCH 24/31] ReactJS: javadoc++ --- .../micronaut/views/react/util/JavaUtilLoggingToSLF4J.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java b/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java index f34656bfc..0c5a5f326 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java @@ -29,6 +29,11 @@ public class JavaUtilLoggingToSLF4J extends Handler { private final Logger logger; + /** + * Constructs a handler that will forward messages to the given SLF4J logger. + * + * @param logger An SLF4J logger that will receive the translated messages. + */ public JavaUtilLoggingToSLF4J(Logger logger) { this.logger = logger; } From 65a1cfec98743e2fa919e903d1f3e948d8b4358b Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Thu, 15 Aug 2024 12:18:03 +0200 Subject: [PATCH 25/31] ReactJS: work around a Truffle bug by going back to reading the bundle resources ourselves. --- .../io/micronaut/views/react/ReactJSBeanFactory.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index ae0f1eeda..fa758c7e9 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -35,7 +35,9 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; @@ -111,8 +113,13 @@ private static Source loadSource(ResourceResolver resolver, String desiredPath, if (sourceURL.isEmpty()) { throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); } - Source.Builder sourceBuilder = Source.newBuilder("js", sourceURL.get()); - return sourceBuilder.mimeType("application/javascript+module").build(); + URL url = sourceURL.get(); + try (var reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + String path = url.getPath(); + var fileName = path.substring(path.lastIndexOf('/') + 1); + Source.Builder sourceBuilder = Source.newBuilder("js", reader, fileName); + return sourceBuilder.mimeType("application/javascript+module").build(); + } } synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { From c72f0c067c243f568ee8cda435ac31e03a84ebe1 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 26 Aug 2024 10:58:46 +0200 Subject: [PATCH 26/31] ReactJS: Extract the qualifier to a constant, mark the ReactJSBeanFactory as final. --- .../micronaut/views/react/ReactJSBeanFactory.java | 10 ++++++---- .../io/micronaut/views/react/ReactJSContext.java | 13 ++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index fa758c7e9..35a016054 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -49,7 +49,9 @@ */ @Factory @Internal -class ReactJSBeanFactory { +final class ReactJSBeanFactory { + static final String REACT_QUALIFIER = "react"; + private static final Logger LOG = LoggerFactory.getLogger("js"); // We cache the Source objects because they are expensive to create, but, we don't want them @@ -65,7 +67,7 @@ class ReactJSBeanFactory { * allowing sandboxed JS to extend or implement Java types. */ @Singleton - @Named("react") + @Named(REACT_QUALIFIER) HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { return configuration.getSandbox() ? HostAccess.newBuilder(HostAccess.CONSTRAINED).allowListAccess(true).allowMapAccess(true).build() @@ -78,7 +80,7 @@ BeanPool contextPool(ApplicationContext applicationContext) { } @Singleton - @Named("react") + @Named(REACT_QUALIFIER) Engine engine(ReactViewsRendererConfiguration configuration) { boolean sandbox = configuration.getSandbox(); LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); @@ -91,7 +93,7 @@ Engine engine(ReactViewsRendererConfiguration configuration) { } @Bean - @Named("react") + @Named(REACT_QUALIFIER) synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException { if (serverBundle == null) { serverBundle = loadSource(resolver, reactConfiguration.getServerBundlePath(), ".server-bundle-path"); diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index c8861ed29..ec5a105e0 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -24,11 +24,18 @@ import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Named; -import org.graalvm.polyglot.*; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.SandboxPolicy; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; import java.util.List; import java.util.stream.Stream; +import static io.micronaut.views.react.ReactJSBeanFactory.REACT_QUALIFIER; + /** * A bean that handles the Javascript {@link Context} object representing a loaded execution * environment usable by one thread at a time. @@ -51,8 +58,8 @@ class ReactJSContext implements AutoCloseable { @Inject ReactJSContext(ReactViewsRendererConfiguration configuration, - @Named("react") Engine engine, - @Named("react") HostAccess hostAccess, + @Named(REACT_QUALIFIER) Engine engine, + @Named(REACT_QUALIFIER) HostAccess hostAccess, ApplicationContext applicationContext) { this.configuration = configuration; this.engine = engine; From 65dc9e775bcd762a19a21806f12352246b5eaa13 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 7 Oct 2024 14:09:12 +0200 Subject: [PATCH 27/31] ReactJS: Add a lambda helper to BeanPool. --- .../views/react/ReactViewsRenderer.java | 13 +++++----- .../micronaut/views/react/util/BeanPool.java | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 57c693543..19c38f3a3 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -62,15 +62,18 @@ class ReactViewsRenderer implements ViewsRenderer>, * or introspectable object), returns hydratable HTML that can be booted on the client using * the React libraries. * - * @param viewName The function or class name of the React component to use as the root. It should return an html root tag. + * @param viewName The function or class name of the React component to use as the root. It should return an HTML root tag. * @param props If non-null, will be exposed to the given component as React props. * @param request The HTTP request object. */ @Override public @NonNull Writable render(@NonNull String viewName, @Nullable PROPS props, @Nullable HttpRequest request) { return writer -> { - try (BeanPool.Handle contextHandle = beanPool.checkOut()) { - render(viewName, props, writer, contextHandle.get(), request); + try { + beanPool.useContext(handle -> { + render(viewName, props, writer, handle.get(), request); + return null; + }); } catch (BeanInstantiationException e) { throw e; } catch (Exception e) { @@ -82,9 +85,7 @@ class ReactViewsRenderer implements ViewsRenderer>, @Override public boolean exists(@NonNull String viewName) { - try (var contextHandle = beanPool.checkOut()) { - return contextHandle.get().moduleHasMember(viewName); - } + return beanPool.useContext(handle -> handle.get().moduleHasMember(viewName)); } private void render(String componentName, PROPS props, Writer writer, ReactJSContext context, @Nullable HttpRequest request) { diff --git a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java index b1616cd23..55c5bcba0 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -16,6 +16,7 @@ package io.micronaut.views.react.util; import io.micronaut.core.annotation.Internal; +import io.micronaut.core.util.functional.ThrowingFunction; import java.io.Closeable; import java.io.IOException; @@ -115,6 +116,29 @@ public synchronized void checkIn(Handle handle) { } } + /** + * Runs the block with a handle checked out, checking it back in again at the end even if an + * exception is thrown. + * + *

+ * This is implemented equivalently to: + *

+     * try (Handle handle = checkOut()) {
+     *     return block.apply(handle);
+     * }
+     * 
+ * + * @param block The lambda that will be executed with the checked out handle. + * @param Return type of the block. + * @param What type of exception the block can throw. + * @return the same object returned by the block. + */ + public R useContext(ThrowingFunction, R, E> block) throws E { + try (Handle handle = checkOut()) { + return block.apply(handle); + } + } + /** * Empties the pool. Beans currently checked out with {@link #checkOut()} will not be re-added * to the pool when {@link #checkIn(Handle)} is called, and may be closed if they are From b2c1d33667e32a2723d016a0043bfb305c086f68 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 14 Oct 2024 14:05:08 +0200 Subject: [PATCH 28/31] ReactJS: Fix BeanPool javadoc. --- .../src/main/java/io/micronaut/views/react/util/BeanPool.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java index 55c5bcba0..ee96186d7 100644 --- a/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -122,8 +122,9 @@ public synchronized void checkIn(Handle handle) { * *

* This is implemented equivalently to: + * *

-     * try (Handle handle = checkOut()) {
+     * try (Handle<T> handle = checkOut()) {
      *     return block.apply(handle);
      * }
      * 
@@ -131,6 +132,7 @@ public synchronized void checkIn(Handle handle) { * @param block The lambda that will be executed with the checked out handle. * @param Return type of the block. * @param What type of exception the block can throw. + * @throws E if thrown by {@code block}. * @return the same object returned by the block. */ public R useContext(ThrowingFunction, R, E> block) throws E { From 2b9f9a5a532b6f408b93062ee88ec19a39892416 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 14 Oct 2024 14:37:27 +0200 Subject: [PATCH 29/31] ReactJS: More refactorings. 1. ContextPoolManager now encapsulates source loading and reloading. 2. Encapsulated @Named("react") behind a custom annotation. 3. The named qualifier resolver is no longer needed. --- .../views/react/ContextPoolManager.java | 114 ++++++++++++++++++ .../io/micronaut/views/react/ReactBean.java | 10 ++ .../views/react/ReactJSBeanFactory.java | 77 +----------- .../micronaut/views/react/ReactJSContext.java | 47 ++------ .../views/react/ReactViewsRenderer.java | 20 +-- 5 files changed, 140 insertions(+), 128 deletions(-) create mode 100644 views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java create mode 100644 views-react/src/main/java/io/micronaut/views/react/ReactBean.java diff --git a/views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java b/views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java new file mode 100644 index 000000000..2e7dd2cb8 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java @@ -0,0 +1,114 @@ +package io.micronaut.views.react; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.event.ApplicationEventListener; +import io.micronaut.core.io.ResourceResolver; +import io.micronaut.scheduling.io.watch.event.FileChangedEvent; +import io.micronaut.scheduling.io.watch.event.WatchEventType; +import io.micronaut.views.react.util.BeanPool; +import jakarta.inject.Singleton; +import org.graalvm.polyglot.Source; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.Optional; + +import static java.lang.String.format; + +/** + * Loads source code for the scripts, reloads them on file change and manages the {@link BeanPool context pool}. + */ +@Factory +class ContextPoolManager implements ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(ContextPoolManager.class); + private final ResourceResolver resourceResolver; + private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; + private final ApplicationContext applicationContext; + // We cache the Source objects because they are expensive to create, but, we don't want them + // to be singleton beans so we can recreate them on file change. + private Source serverBundle; // L(this) + private Source renderScript; // L(this) + private BeanPool pool; // L(this) + + public ContextPoolManager(ResourceResolver resourceResolver, ReactViewsRendererConfiguration reactViewsRendererConfiguration, ApplicationContext applicationContext) { + this.resourceResolver = resourceResolver; + this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; + this.applicationContext = applicationContext; + } + + @Singleton + public synchronized BeanPool contextPool() { + if (pool != null) { + pool.clear(); + } + pool = new BeanPool<>(this::createContext); + return pool; + } + + private ReactJSContext createContext() { + try { + return applicationContext.createBean(ReactJSContext.class, serverBundle(), renderScript()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private synchronized Source serverBundle() throws IOException { + if (serverBundle == null) { + serverBundle = loadSource(resourceResolver, reactViewsRendererConfiguration.getServerBundlePath(), ".server-bundle-path"); + } + return serverBundle; + } + + private synchronized Source renderScript() throws IOException { + if (renderScript == null) { + renderScript = loadSource(resourceResolver, reactViewsRendererConfiguration.getRenderScript(), ".render-script"); + } + return renderScript; + } + + private static Source loadSource(ResourceResolver resolver, String desiredPath, String propName) throws IOException { + Optional sourceURL = resolver.getResource(desiredPath); + if (sourceURL.isEmpty()) { + throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); + } + URL url = sourceURL.get(); + try (var reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + String path = url.getPath(); + var fileName = path.substring(path.lastIndexOf('/') + 1); + Source.Builder sourceBuilder = Source.newBuilder("js", reader, fileName); + return sourceBuilder.mimeType("application/javascript+module").build(); + } + } + + @Override + public synchronized void onApplicationEvent(FileChangedEvent event) { + if (event.getEventType() == WatchEventType.DELETE) { + return; + } + + var path = event.getPath().toAbsolutePath(); + if (path.equals(Paths.get(serverBundle.getPath()).toAbsolutePath())) { + serverBundle = null; + } + if (path.equals(Paths.get(renderScript.getPath()).toAbsolutePath())) { + renderScript = null; + } + + if (serverBundle != null && renderScript != null) { + return; + } + + // Clearing the pool ensures that new requests go via the pool and from there, back to + // createContext() which will in turn then reload the files on disk. + pool.clear(); + LOG.info("Reloaded React SSR bundle due to file change."); + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactBean.java b/views-react/src/main/java/io/micronaut/views/react/ReactBean.java new file mode 100644 index 000000000..b260e763e --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactBean.java @@ -0,0 +1,10 @@ +package io.micronaut.views.react; + +import jakarta.inject.Named; + +/** + * Used to separate generic Polyglot types instantiated just for ReactJS integration. + */ +@Named("react") +@interface ReactBean { +} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 35a016054..83524151e 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -15,50 +15,26 @@ */ package io.micronaut.views.react; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.io.ResourceResolver; -import io.micronaut.views.react.util.BeanPool; import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; import io.micronaut.views.react.util.OutputStreamToSLF4J; -import jakarta.inject.Named; import jakarta.inject.Singleton; import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.SandboxPolicy; -import org.graalvm.polyglot.Source; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Optional; - -import static java.lang.String.format; - /** * Allows the default Javascript context and host access policy to be controlled. */ @Factory @Internal final class ReactJSBeanFactory { - static final String REACT_QUALIFIER = "react"; - private static final Logger LOG = LoggerFactory.getLogger("js"); - // We cache the Source objects because they are expensive to create, but, we don't want them - // to be singleton beans so we can recreate them on file change. - private Source serverBundle; // L(this) - private Source renderScript; // L(this) - /** * This defaults to {@link HostAccess#ALL} if the sandbox is disabled, or {@link * HostAccess#CONSTRAINED} if it's on. By replacing the {@link HostAccess} bean you can @@ -67,7 +43,7 @@ final class ReactJSBeanFactory { * allowing sandboxed JS to extend or implement Java types. */ @Singleton - @Named(REACT_QUALIFIER) + @ReactBean HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { return configuration.getSandbox() ? HostAccess.newBuilder(HostAccess.CONSTRAINED).allowListAccess(true).allowMapAccess(true).build() @@ -75,12 +51,7 @@ HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { } @Singleton - BeanPool contextPool(ApplicationContext applicationContext) { - return new BeanPool<>(() -> applicationContext.createBean(ReactJSContext.class)); - } - - @Singleton - @Named(REACT_QUALIFIER) + @ReactBean Engine engine(ReactViewsRendererConfiguration configuration) { boolean sandbox = configuration.getSandbox(); LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); @@ -91,48 +62,4 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED) .build(); } - - @Bean - @Named(REACT_QUALIFIER) - synchronized Source serverBundle(ResourceResolver resolver, ReactViewsRendererConfiguration reactConfiguration) throws IOException { - if (serverBundle == null) { - serverBundle = loadSource(resolver, reactConfiguration.getServerBundlePath(), ".server-bundle-path"); - } - return serverBundle; - } - - @Bean - @Named("react-render-script") - synchronized Source renderScript(ResourceResolver resolver, ReactViewsRendererConfiguration config) throws IOException { - if (renderScript == null) { - renderScript = loadSource(resolver, config.getRenderScript(), ".render-script"); - } - return renderScript; - } - - private static Source loadSource(ResourceResolver resolver, String desiredPath, String propName) throws IOException { - Optional sourceURL = resolver.getResource(desiredPath); - if (sourceURL.isEmpty()) { - throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); - } - URL url = sourceURL.get(); - try (var reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { - String path = url.getPath(); - var fileName = path.substring(path.lastIndexOf('/') + 1); - Source.Builder sourceBuilder = Source.newBuilder("js", reader, fileName); - return sourceBuilder.mimeType("application/javascript+module").build(); - } - } - - synchronized boolean maybeReloadServerBundle(Path fileThatChanged) { - if (serverBundle != null && fileThatChanged.toAbsolutePath().equals(Paths.get(serverBundle.getPath()).toAbsolutePath())) { - serverBundle = null; - return true; - } - if (renderScript != null && fileThatChanged.toAbsolutePath().equals(Paths.get(renderScript.getPath()).toAbsolutePath())) { - renderScript = null; - return true; - } - return false; - } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index ec5a105e0..7d78114ae 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -15,26 +15,15 @@ */ package io.micronaut.views.react; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.Qualifier; import io.micronaut.context.annotation.Bean; +import io.micronaut.context.annotation.Parameter; import io.micronaut.core.annotation.Internal; -import io.micronaut.inject.BeanType; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; -import jakarta.inject.Named; -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.Engine; -import org.graalvm.polyglot.HostAccess; -import org.graalvm.polyglot.SandboxPolicy; -import org.graalvm.polyglot.Source; -import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.*; import java.util.List; -import java.util.stream.Stream; - -import static io.micronaut.views.react.ReactJSBeanFactory.REACT_QUALIFIER; /** * A bean that handles the Javascript {@link Context} object representing a loaded execution @@ -53,18 +42,21 @@ class ReactJSContext implements AutoCloseable { private final Engine engine; private final HostAccess hostAccess; - private final ApplicationContext applicationContext; + private final Source ssrModuleSource; + private final Source renderSource; private final ReactViewsRendererConfiguration configuration; @Inject ReactJSContext(ReactViewsRendererConfiguration configuration, - @Named(REACT_QUALIFIER) Engine engine, - @Named(REACT_QUALIFIER) HostAccess hostAccess, - ApplicationContext applicationContext) { + @ReactBean Engine engine, + @ReactBean HostAccess hostAccess, + @Parameter Source ssrModuleSource, + @Parameter Source renderSource) { this.configuration = configuration; this.engine = engine; this.hostAccess = hostAccess; - this.applicationContext = applicationContext; + this.ssrModuleSource = ssrModuleSource; + this.renderSource = renderSource; } @PostConstruct @@ -72,7 +64,7 @@ void init() { polyglotContext = createContext(); Value global = polyglotContext.getBindings("js"); - ssrModule = loadNamedModule(NamedSourceQualifier.ssr); + ssrModule = polyglotContext.eval(ssrModuleSource); // Take all the exports from the components bundle, and expose them to the render script. for (var name : ssrModule.getMemberKeys()) { @@ -80,18 +72,13 @@ void init() { } // Evaluate our JS-side framework specific render logic. - Value renderModule = loadNamedModule(NamedSourceQualifier.renderScript); + Value renderModule = polyglotContext.eval(renderSource); render = renderModule.getMember("ssr"); if (render == null) { throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); } } - private Value loadNamedModule(NamedSourceQualifier sourceQualifier) { - // This will return a cached, shared Source object. - return polyglotContext.eval(applicationContext.createBean(Source.class, sourceQualifier)); - } - private Context createContext() { var contextBuilder = Context.newBuilder() .engine(engine) @@ -141,14 +128,4 @@ boolean moduleHasMember(String memberName) { public synchronized void close() { polyglotContext.close(); } - - private record NamedSourceQualifier(String name) implements Qualifier { - static NamedSourceQualifier ssr = new NamedSourceQualifier("react"); - static NamedSourceQualifier renderScript = new NamedSourceQualifier("react-render-script"); - - @Override - public > Stream reduce(Class beanType, Stream candidates) { - return candidates.filter(bt -> name.equals(bt.getBeanName().orElse(null))); - } - } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 19c38f3a3..623fe7e43 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -15,23 +15,18 @@ */ package io.micronaut.views.react; -import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.context.exceptions.BeanInstantiationException; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.Writable; import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; -import io.micronaut.scheduling.io.watch.event.FileChangedEvent; -import io.micronaut.scheduling.io.watch.event.WatchEventType; import io.micronaut.views.ViewsRenderer; import io.micronaut.views.react.truffle.IntrospectableToTruffleAdapter; import io.micronaut.views.react.util.BeanPool; import jakarta.inject.Singleton; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.Value; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Writer; @@ -44,17 +39,13 @@ * @param An introspectable bean type that will be fed to the ReactJS root component as props. */ @Singleton -class ReactViewsRenderer implements ViewsRenderer>, ApplicationEventListener { - private static final Logger LOG = LoggerFactory.getLogger(ReactViewsRenderer.class); - +class ReactViewsRenderer implements ViewsRenderer> { private final BeanPool beanPool; private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; - private final ReactJSBeanFactory reactJSBeanFactory; - ReactViewsRenderer(BeanPool beanPool, ReactViewsRendererConfiguration reactViewsRendererConfiguration, ReactJSBeanFactory reactJSBeanFactory) { + ReactViewsRenderer(BeanPool beanPool, ReactViewsRendererConfiguration reactViewsRendererConfiguration) { this.beanPool = beanPool; this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; - this.reactJSBeanFactory = reactJSBeanFactory; } /** @@ -103,13 +94,6 @@ private void render(String componentName, PROPS props, Writer writer, ReactJSCon context.render.executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); } - @Override - public void onApplicationEvent(FileChangedEvent event) { - if (event.getEventType() != WatchEventType.DELETE && reactJSBeanFactory.maybeReloadServerBundle(event.getPath())) { - beanPool.clear(); - LOG.info("Reloaded React SSR bundle due to file change."); - } - } /** * Methods exposed to the ReactJS components and render scripts. Needs to be public to be From 7de0ffe49820fe816e07e34761914ff412bf0986 Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Tue, 15 Oct 2024 10:11:05 +0200 Subject: [PATCH 30/31] ReactJS: Even more refactorings. --- .../views/react/ReactJSBeanFactory.java | 86 +++++++++++++++++ .../micronaut/views/react/ReactJSContext.java | 96 ++----------------- ...xtPoolManager.java => ReactJSSources.java} | 70 ++++++-------- .../react/ReactJSSourcesChangedEvent.java | 31 ++++++ .../views/react/ReactViewsRenderer.java | 6 +- 5 files changed, 155 insertions(+), 134 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{ContextPoolManager.java => ReactJSSources.java} (55%) create mode 100644 views-react/src/main/java/io/micronaut/views/react/ReactJSSourcesChangedEvent.java diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java index 83524151e..a1b506cf7 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -15,14 +15,20 @@ */ package io.micronaut.views.react; +import io.micronaut.context.ApplicationContext; import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Prototype; +import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.core.annotation.Internal; +import io.micronaut.views.react.util.BeanPool; import io.micronaut.views.react.util.JavaUtilLoggingToSLF4J; import io.micronaut.views.react.util.OutputStreamToSLF4J; import jakarta.inject.Singleton; +import org.graalvm.polyglot.Context; import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.SandboxPolicy; +import org.graalvm.polyglot.Value; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; @@ -62,4 +68,84 @@ Engine engine(ReactViewsRendererConfiguration configuration) { .sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED) .build(); } + + @ReactBean + @Singleton + BeanPool contextPool(ApplicationContext applicationContext) { + return new BeanPool<>(() -> applicationContext.createBean(ReactJSContext.class)); + } + + @Singleton + ApplicationEventListener poolCleaner(BeanPool contextPool) { + // Clearing the pool ensures that new requests go via the pool and from there, back to + // createContext() which will in turn then reload the files on disk. + return event -> contextPool.clear(); + } + + @ReactBean + @Singleton + Context polyglotContext(@ReactBean Engine engine, + @ReactBean HostAccess hostAccess, + ReactViewsRendererConfiguration configuration) { + var contextBuilder = Context.newBuilder() + .engine(engine) + .option("js.esm-eval-returns-exports", "true") + .option("js.unhandled-rejections", "throw"); + + if (configuration.getSandbox()) { + contextBuilder + .sandbox(SandboxPolicy.CONSTRAINED) + .allowHostAccess(hostAccess); + } else { + // allowExperimentalOptions is here because as of the time of writing (August 2024) + // the esm-eval-returns-exports option is experimental. That got fixed and this + // can be removed once the base version of GraalJS is bumped to 24.1 or higher. + contextBuilder + .sandbox(SandboxPolicy.TRUSTED) + .allowAllAccess(true) + .allowExperimentalOptions(true); + } + + try { + return contextBuilder.build(); + } catch (ExceptionInInitializerError e) { + // The catch handler is to work around a bug in Polyglot 24.0.0 + if (e.getCause().getMessage().contains("version compatibility check failed")) { + throw new IllegalStateException("GraalJS version mismatch or it's missing. Please ensure you have added either org.graalvm.polyglot:js or org.graalvm.polyglot:js-community to your dependencies alongside Micronaut Views React, as it's up to you to select the best engine given your licensing constraints. See the user guide for more detail."); + } else { + throw e; + } + } catch (IllegalArgumentException e) { + // We need esm-eval-returns-exports=true, but it's not compatible with the sandbox in this version of GraalJS. + if (e.getMessage().contains("Option 'js.esm-eval-returns-exports' is experimental")) { + throw new IllegalStateException("The sandboxing feature requires a newer version of GraalJS. Please upgrade and try again, or disable the sandboxing feature."); + } else { + throw e; + } + } + } + + @Prototype + ReactJSContext reactJsContext(@ReactBean Context polyglotContext, + ReactViewsRendererConfiguration configuration, + ReactJSSources reactJSSources) { + + Value global = polyglotContext.getBindings("js"); + Value ssrModule = polyglotContext.eval(reactJSSources.serverBundle()); + + // Take all the exports from the components bundle, and expose them to the render script. + for (var name : ssrModule.getMemberKeys()) { + global.putMember(name, ssrModule.getMember(name)); + } + + // Evaluate our JS-side framework specific render logic. + Value renderModule = polyglotContext.eval(reactJSSources.renderScript()); + Value render = renderModule.getMember("ssr"); + if (render == null) { + throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); + } + + return new ReactJSContext(polyglotContext, render, ssrModule); + } + } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index 7d78114ae..5820d9085 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -16,12 +16,10 @@ package io.micronaut.views.react; import io.micronaut.context.annotation.Bean; -import io.micronaut.context.annotation.Parameter; import io.micronaut.core.annotation.Internal; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import jakarta.inject.Inject; -import org.graalvm.polyglot.*; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; import java.util.List; @@ -31,93 +29,13 @@ */ @Internal @Bean -class ReactJSContext implements AutoCloseable { +record ReactJSContext(Context polyglotContext, + Value render, + Value ssrModule) implements AutoCloseable { + // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); - // Accessed from ReactViewsRenderer - Context polyglotContext; - Value render; - Value ssrModule; - - private final Engine engine; - private final HostAccess hostAccess; - private final Source ssrModuleSource; - private final Source renderSource; - private final ReactViewsRendererConfiguration configuration; - - @Inject - ReactJSContext(ReactViewsRendererConfiguration configuration, - @ReactBean Engine engine, - @ReactBean HostAccess hostAccess, - @Parameter Source ssrModuleSource, - @Parameter Source renderSource) { - this.configuration = configuration; - this.engine = engine; - this.hostAccess = hostAccess; - this.ssrModuleSource = ssrModuleSource; - this.renderSource = renderSource; - } - - @PostConstruct - void init() { - polyglotContext = createContext(); - - Value global = polyglotContext.getBindings("js"); - ssrModule = polyglotContext.eval(ssrModuleSource); - - // Take all the exports from the components bundle, and expose them to the render script. - for (var name : ssrModule.getMemberKeys()) { - global.putMember(name, ssrModule.getMember(name)); - } - - // Evaluate our JS-side framework specific render logic. - Value renderModule = polyglotContext.eval(renderSource); - render = renderModule.getMember("ssr"); - if (render == null) { - throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); - } - } - - private Context createContext() { - var contextBuilder = Context.newBuilder() - .engine(engine) - .option("js.esm-eval-returns-exports", "true") - .option("js.unhandled-rejections", "throw"); - - if (configuration.getSandbox()) { - contextBuilder - .sandbox(SandboxPolicy.CONSTRAINED) - .allowHostAccess(hostAccess); - } else { - // allowExperimentalOptions is here because as of the time of writing (August 2024) - // the esm-eval-returns-exports option is experimental. That got fixed and this - // can be removed once the base version of GraalJS is bumped to 24.1 or higher. - contextBuilder - .sandbox(SandboxPolicy.TRUSTED) - .allowAllAccess(true) - .allowExperimentalOptions(true); - } - - try { - return contextBuilder.build(); - } catch (ExceptionInInitializerError e) { - // The catch handler is to work around a bug in Polyglot 24.0.0 - if (e.getCause().getMessage().contains("version compatibility check failed")) { - throw new IllegalStateException("GraalJS version mismatch or it's missing. Please ensure you have added either org.graalvm.polyglot:js or org.graalvm.polyglot:js-community to your dependencies alongside Micronaut Views React, as it's up to you to select the best engine given your licensing constraints. See the user guide for more detail."); - } else { - throw e; - } - } catch (IllegalArgumentException e) { - // We need esm-eval-returns-exports=true, but it's not compatible with the sandbox in this version of GraalJS. - if (e.getMessage().contains("Option 'js.esm-eval-returns-exports' is experimental")) { - throw new IllegalStateException("The sandboxing feature requires a newer version of GraalJS. Please upgrade and try again, or disable the sandboxing feature."); - } else { - throw e; - } - } - } - boolean moduleHasMember(String memberName) { assert !IMPORT_SYMBOLS.contains(memberName) : "Should not query the server-side bundle for member name " + memberName; return ssrModule.hasMember(memberName); @@ -125,7 +43,7 @@ boolean moduleHasMember(String memberName) { @PreDestroy @Override - public synchronized void close() { + public void close() { polyglotContext.close(); } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java similarity index 55% rename from views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java rename to views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java index 2e7dd2cb8..a7471c3a1 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ContextPoolManager.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java @@ -1,8 +1,7 @@ package io.micronaut.views.react; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.annotation.Factory; import io.micronaut.context.event.ApplicationEventListener; +import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.core.io.ResourceResolver; import io.micronaut.scheduling.io.watch.event.FileChangedEvent; import io.micronaut.scheduling.io.watch.event.WatchEventType; @@ -25,66 +24,55 @@ /** * Loads source code for the scripts, reloads them on file change and manages the {@link BeanPool context pool}. */ -@Factory -class ContextPoolManager implements ApplicationEventListener { - private static final Logger LOG = LoggerFactory.getLogger(ContextPoolManager.class); +@Singleton +class ReactJSSources implements ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(ReactJSSources.class); private final ResourceResolver resourceResolver; private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; - private final ApplicationContext applicationContext; + private final ApplicationEventPublisher sourcesChangedEventPublisher; + // We cache the Source objects because they are expensive to create, but, we don't want them // to be singleton beans so we can recreate them on file change. private Source serverBundle; // L(this) private Source renderScript; // L(this) - private BeanPool pool; // L(this) - public ContextPoolManager(ResourceResolver resourceResolver, ReactViewsRendererConfiguration reactViewsRendererConfiguration, ApplicationContext applicationContext) { + ReactJSSources(ResourceResolver resourceResolver, + ReactViewsRendererConfiguration reactViewsRendererConfiguration, + ApplicationEventPublisher sourcesChangedEventPublisher) { this.resourceResolver = resourceResolver; this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; - this.applicationContext = applicationContext; - } - - @Singleton - public synchronized BeanPool contextPool() { - if (pool != null) { - pool.clear(); - } - pool = new BeanPool<>(this::createContext); - return pool; - } - - private ReactJSContext createContext() { - try { - return applicationContext.createBean(ReactJSContext.class, serverBundle(), renderScript()); - } catch (IOException e) { - throw new RuntimeException(e); - } + this.sourcesChangedEventPublisher = sourcesChangedEventPublisher; } - private synchronized Source serverBundle() throws IOException { + synchronized Source serverBundle() { if (serverBundle == null) { serverBundle = loadSource(resourceResolver, reactViewsRendererConfiguration.getServerBundlePath(), ".server-bundle-path"); } return serverBundle; } - private synchronized Source renderScript() throws IOException { + synchronized Source renderScript() { if (renderScript == null) { renderScript = loadSource(resourceResolver, reactViewsRendererConfiguration.getRenderScript(), ".render-script"); } return renderScript; } - private static Source loadSource(ResourceResolver resolver, String desiredPath, String propName) throws IOException { - Optional sourceURL = resolver.getResource(desiredPath); - if (sourceURL.isEmpty()) { - throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); - } - URL url = sourceURL.get(); - try (var reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { - String path = url.getPath(); - var fileName = path.substring(path.lastIndexOf('/') + 1); - Source.Builder sourceBuilder = Source.newBuilder("js", reader, fileName); - return sourceBuilder.mimeType("application/javascript+module").build(); + private static Source loadSource(ResourceResolver resolver, String desiredPath, String propName) { + try { + Optional sourceURL = resolver.getResource(desiredPath); + if (sourceURL.isEmpty()) { + throw new FileNotFoundException(format("Javascript %s could not be found. Check your %s property.", desiredPath, ReactViewsRendererConfiguration.PREFIX + propName)); + } + URL url = sourceURL.get(); + try (var reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + String path = url.getPath(); + var fileName = path.substring(path.lastIndexOf('/') + 1); + Source.Builder sourceBuilder = Source.newBuilder("js", reader, fileName); + return sourceBuilder.mimeType("application/javascript+module").build(); + } + } catch (IOException e) { + throw new RuntimeException(e); } } @@ -106,9 +94,7 @@ public synchronized void onApplicationEvent(FileChangedEvent event) { return; } - // Clearing the pool ensures that new requests go via the pool and from there, back to - // createContext() which will in turn then reload the files on disk. - pool.clear(); LOG.info("Reloaded React SSR bundle due to file change."); + sourcesChangedEventPublisher.publishEvent(new ReactJSSourcesChangedEvent(this)); } } diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSSourcesChangedEvent.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSSourcesChangedEvent.java new file mode 100644 index 000000000..c151d40d9 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSSourcesChangedEvent.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.react; + +import io.micronaut.context.event.ApplicationEvent; + +/** + * The ReactJS sources changed event. + * + * @author Denis Stepanov + */ +final class ReactJSSourcesChangedEvent extends ApplicationEvent { + + public ReactJSSourcesChangedEvent(ReactJSSources source) { + super(source); + } + +} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 623fe7e43..013bb828f 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -80,7 +80,7 @@ public boolean exists(@NonNull String viewName) { } private void render(String componentName, PROPS props, Writer writer, ReactJSContext context, @Nullable HttpRequest request) { - Value component = context.ssrModule.getMember(componentName); + Value component = context.ssrModule().getMember(componentName); if (component == null) { throw new IllegalArgumentException("Component name %s wasn't exported from the SSR module.".formatted(componentName)); } @@ -90,8 +90,8 @@ private void render(String componentName, PROPS props, Writer writer, ReactJSCon // We wrap the props object so we can use Micronaut's compile-time reflection implementation. // This should be more native-image friendly (no need to write reflection config files), and // might also be faster. - Value guestProps = IntrospectableToTruffleAdapter.wrap(context.polyglotContext, props); - context.render.executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); + Value guestProps = IntrospectableToTruffleAdapter.wrap(context.polyglotContext(), props); + context.render().executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); } From 443862ea9dffc24b2d3e1698242cecbd336929c2 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 16 Oct 2024 14:32:15 +0200 Subject: [PATCH 31/31] ReactJS: Fix a bean conflict introduced by the merge and some spotless errors. --- .../java/io/micronaut/views/react/ReactBean.java | 15 +++++++++++++++ .../io/micronaut/views/react/ReactJSContext.java | 9 ++++----- .../io/micronaut/views/react/ReactJSSources.java | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactBean.java b/views-react/src/main/java/io/micronaut/views/react/ReactBean.java index b260e763e..c04bd9687 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactBean.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactBean.java @@ -1,3 +1,18 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.micronaut.views.react; import jakarta.inject.Named; diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java index 5820d9085..835238ab8 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -15,8 +15,6 @@ */ package io.micronaut.views.react; -import io.micronaut.context.annotation.Bean; -import io.micronaut.core.annotation.Internal; import jakarta.annotation.PreDestroy; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.Value; @@ -26,13 +24,14 @@ /** * A bean that handles the Javascript {@link Context} object representing a loaded execution * environment usable by one thread at a time. + * + * @param polyglotContext A single-threaded Javascript language context (global vars etc). + * @param render The {@code ssr} function defined in Javascript. + * @param ssrModule The JS module containing the user's React components. */ -@Internal -@Bean record ReactJSContext(Context polyglotContext, Value render, Value ssrModule) implements AutoCloseable { - // Symbols the user's server side bundle might supply us with. private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java b/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java index a7471c3a1..dd8eec484 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java @@ -1,3 +1,18 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.micronaut.views.react; import io.micronaut.context.event.ApplicationEventListener;