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 ---- - 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. 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 deleted file mode 100644 index 54bb299c3..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java +++ /dev/null @@ -1,71 +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.annotation.PreDestroy; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import org.graalvm.polyglot.Engine; -import org.graalvm.polyglot.Source; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.event.Level; - -import java.io.IOException; - -/** - * Holds the thread-safe {@link Engine} and {@link Source} which together pin compiled machine code - * into the JVM code cache. - */ -@Singleton -@Internal -class CompiledJS implements AutoCloseable { - private static final Logger LOG = LoggerFactory.getLogger("js"); - - final Engine engine; - private Source source; - private final JSBundlePaths jsBundlePaths; - - @Inject - CompiledJS(JSBundlePaths jsBundlePaths, JSEngineLogHandler engineLogHandler, JSSandboxing sandboxing) { - 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; - reload(); - } - - synchronized Source getSource() { - return source; - } - - synchronized void reload() { - try { - source = jsBundlePaths.readServerBundle(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - @PreDestroy - public void close() throws Exception { - engine.close(); - } -} 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 deleted file mode 100644 index 972930924..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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.Singleton;; -import io.micronaut.context.annotation.Factory; -import io.micronaut.core.annotation.Internal; -import org.graalvm.polyglot.HostAccess; - -/** - * Allows the default Javascript context and host access policy to be controlled. - */ -@Factory -@Internal -class JSBeanFactory { - /** - * 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 - HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { - return configuration.getSandbox() - ? HostAccess.CONSTRAINED - : HostAccess.ALL; - } -} 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 deleted file mode 100644 index 6e4f73f86..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.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 io.micronaut.views.ViewsConfiguration; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import org.graalvm.polyglot.Source; - -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 JSBundlePaths { - // Source code file name, for JS stack traces. - final String bundleFileName; - - // URL of bundle file, could be a file:// or in a classpath jar. - final URL bundleURL; - - // If a file:// (during development), the path of that file. Used for hot reloads. - @Nullable - final Path bundlePath; - - @Inject - JSBundlePaths( - ViewsConfiguration viewsConfiguration, - 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()); - } 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(); - } - } -} 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 deleted file mode 100644 index de87c7510..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSContext.java +++ /dev/null @@ -1,159 +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.annotation.Parameter; -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.annotation.NonNull; -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 java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -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. - */ -@Internal -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"); - - // Accessed from ReactViewsRenderer - Context polyglotContext; - 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) { - this.compiledJS = compiledJS; - this.configuration = configuration; - this.sandboxing = sandboxing; - this.versionCounter = versionCounter; - } - - @PostConstruct - void init() throws IOException { - polyglotContext = createContext(); - - Value global = polyglotContext.getBindings("js"); - ssrModule = polyglotContext.eval(compiledJS.getSource()); - - // 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. - Source source = loadRenderSource(); - Value renderModule = polyglotContext.eval(source); - 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 Context createContext() { - var contextBuilder = Context.newBuilder() - .engine(compiledJS.engine) - .option("js.esm-eval-returns-exports", "true") - .option("js.unhandled-rejections", "throw"); - try { - return sandboxing.configure(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); - } - - @PreDestroy - @Override - public synchronized void close() { - polyglotContext.close(); - } -} 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 035a79880..000000000 --- a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java +++ /dev/null @@ -1,90 +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.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; -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 implements ApplicationEventListener { - private static final Logger LOG = LoggerFactory.getLogger(JSContextPool.class); - 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) { - this.applicationContext = applicationContext; - this.paths = paths; - } - - /** - * 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); - } - - synchronized void release(JSContext jsContext) { - // Put it back into the pool for reuse. - 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++; - } - } -} 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); - } - } -} 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..c04bd9687 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactBean.java @@ -0,0 +1,25 @@ +/* + * 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; + +/** + * 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 new file mode 100644 index 000000000..a1b506cf7 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSBeanFactory.java @@ -0,0 +1,151 @@ +/* + * 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.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; + +/** + * Allows the default Javascript context and host access policy to be controlled. + */ +@Factory +@Internal +final 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. + */ + @Singleton + @ReactBean + HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { + return configuration.getSandbox() + ? HostAccess.newBuilder(HostAccess.CONSTRAINED).allowListAccess(true).allowMapAccess(true).build() + : HostAccess.ALL; + } + + @Singleton + @ReactBean + 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(); + } + + @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 new file mode 100644 index 000000000..835238ab8 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSContext.java @@ -0,0 +1,48 @@ +/* + * 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 jakarta.annotation.PreDestroy; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; + +import java.util.List; + +/** + * 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. + */ +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"); + + boolean moduleHasMember(String memberName) { + assert !IMPORT_SYMBOLS.contains(memberName) : "Should not query the server-side bundle for member name " + memberName; + return ssrModule.hasMember(memberName); + } + + @PreDestroy + @Override + public void close() { + polyglotContext.close(); + } +} 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 new file mode 100644 index 000000000..dd8eec484 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactJSSources.java @@ -0,0 +1,115 @@ +/* + * 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; +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; +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}. + */ +@Singleton +class ReactJSSources implements ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(ReactJSSources.class); + private final ResourceResolver resourceResolver; + private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; + 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) + + ReactJSSources(ResourceResolver resourceResolver, + ReactViewsRendererConfiguration reactViewsRendererConfiguration, + ApplicationEventPublisher sourcesChangedEventPublisher) { + this.resourceResolver = resourceResolver; + this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; + this.sourcesChangedEventPublisher = sourcesChangedEventPublisher; + } + + synchronized Source serverBundle() { + if (serverBundle == null) { + serverBundle = loadSource(resourceResolver, reactViewsRendererConfiguration.getServerBundlePath(), ".server-bundle-path"); + } + return serverBundle; + } + + 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) { + 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); + } + } + + @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; + } + + 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 612b36d89..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 @@ -15,13 +15,15 @@ */ 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; import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; import io.micronaut.views.ViewsRenderer; -import jakarta.inject.Inject; +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; @@ -37,18 +39,13 @@ * @param An introspectable bean type that will be fed to the ReactJS root component as props. */ @Singleton -public class ReactViewsRenderer implements ViewsRenderer> { - @Inject - ReactViewsRendererConfiguration reactConfiguration; +class ReactViewsRenderer implements ViewsRenderer> { + private final BeanPool beanPool; + private final ReactViewsRendererConfiguration reactViewsRendererConfiguration; - @Inject - JSContextPool contextPool; - - /** - * 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) { + this.beanPool = beanPool; + this.reactViewsRendererConfiguration = reactViewsRendererConfiguration; } /** @@ -56,37 +53,34 @@ public ReactViewsRenderer() { * 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 -> { - JSContext context = contextPool.acquire(); try { - render(viewName, props, writer, context, request); + beanPool.useContext(handle -> { + render(viewName, props, writer, handle.get(), request); + return null; + }); + } 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); - } + return beanPool.useContext(handle -> handle.get().moduleHasMember(viewName)); } - private void render(String componentName, PROPS props, Writer writer, JSContext context, @Nullable HttpRequest request) { - Value component = context.ssrModule.getMember(componentName); + 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)); } @@ -96,10 +90,11 @@ 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); - context.render.executeVoid(component, guestProps, renderCallback, reactConfiguration.getClientBundleURL(), request); + Value guestProps = IntrospectableToTruffleAdapter.wrap(context.polyglotContext(), props); + context.render().executeVoid(component, guestProps, renderCallback, reactViewsRendererConfiguration.getClientBundleURL(), request); } + /** * Methods exposed to the ReactJS components and render scripts. Needs to be public to be * callable from the JS side. 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/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/truffle/IntrospectableToTruffleAdapter.java similarity index 84% 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..6d035cc5e 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,27 @@ * 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}. + * 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()} */ - 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 +71,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/main/java/io/micronaut/views/react/util/BeanPool.java b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java new file mode 100644 index 000000000..ee96186d7 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/util/BeanPool.java @@ -0,0 +1,206 @@ +/* + * 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.util; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.util.functional.ThrowingFunction; + +import java.io.Closeable; +import java.io.IOException; +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. + *

+ * + *

+ * 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. + *

+ * + * @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 final Supplier factory; + + // 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. + 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. 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) { + pool.add(new SoftReference<>(impl)); + } else if (impl.obj instanceof Closeable closeable) { + try { + closeable.close(); + } catch (IOException ignored) { + } + } + } + + /** + * 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<T> 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. + * @throws E if thrown by {@code block}. + * @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 + * {@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) { + 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(); + } + + + /** + * 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/JSEngineLogHandler.java b/views-react/src/main/java/io/micronaut/views/react/util/JavaUtilLoggingToSLF4J.java similarity index 65% 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..0c5a5f326 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,18 @@ * 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; + + /** + * 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; + } @Override public void publish(LogRecord record) { @@ -38,11 +44,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); } } 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 84% 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..0928dd473 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); @@ -39,6 +39,8 @@ 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)); 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..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 @@ -1,6 +1,8 @@ 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 import org.graalvm.polyglot.proxy.ProxyObject @@ -9,16 +11,16 @@ 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: - ProxyObject proxy = ProxyObjectWithIntrospectableSupport.wrap(context, bean).asProxyObject() + ProxyObject proxy = IntrospectableToTruffleAdapter.wrap(context, bean).asProxyObject() context.getBindings("js").putMember("bean", proxy) then: @@ -29,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() 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