From dfbe682c0d87f0723103bd6ca51c2980ed98c800 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Mon, 26 Aug 2024 17:04:02 +0200 Subject: [PATCH] feat: add support for client endpoints live reload Adds the possibility to watch for changes in Hilla Java endpoints and trigger Quarkus live reload without the need to refresh the brower page. Quarkus will recompile the changed class and restart the server if needed. If 'quarkus.live-reload.instrumentation' is enabled, Quarkus may redefine the class without a server restart. In this case, the extension directly triggers Hilla hotswapper to regenerate client side code. Fixes #261 Fixes #489 --- .../QuarkusHillaExtensionProcessor.java | 21 +- ...java => OffendingMethodCallsReplacer.java} | 8 +- .../application/HelloWorldService.java | 4 + .../quarkus/hilla/HillaConfiguration.java | 92 ++++++ ...uarkusEndpointControllerConfiguration.java | 14 +- .../quarkus/hilla/SpringReplacements.java | 8 + .../reload/AbstractEndpointsWatcher.java | 267 ++++++++++++++++++ .../hilla/reload/EndpointClassesWatcher.java | 74 +++++ .../hilla/reload/EndpointSourcesWatcher.java | 100 +++++++ .../reload/EndpointsHotReplacementSetup.java | 37 +++ .../hilla/reload/HillaLiveReloadRecorder.java | 78 +++++ .../io.quarkus.dev.spi.HotReplacementSetup | 18 ++ 12 files changed, 715 insertions(+), 6 deletions(-) rename deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/{SpringReplacer.java => OffendingMethodCallsReplacer.java} (92%) create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/HillaConfiguration.java create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/AbstractEndpointsWatcher.java create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointSourcesWatcher.java create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointsHotReplacementSetup.java create mode 100644 runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/HillaLiveReloadRecorder.java create mode 100644 runtime-commons/src/main/resources/META-INF/services/io.quarkus.dev.spi.HotReplacementSetup diff --git a/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/QuarkusHillaExtensionProcessor.java b/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/QuarkusHillaExtensionProcessor.java index 4cf5fdda..76438ba1 100644 --- a/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/QuarkusHillaExtensionProcessor.java +++ b/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/QuarkusHillaExtensionProcessor.java @@ -48,6 +48,7 @@ import io.quarkus.builder.item.MultiBuildItem; import io.quarkus.builder.item.SimpleBuildItem; import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Consume; @@ -61,6 +62,7 @@ import io.quarkus.deployment.builditem.ExcludeDependencyBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; +import io.quarkus.deployment.builditem.LiveReloadBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.pkg.NativeConfig; import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; @@ -82,6 +84,7 @@ import com.github.mcollovati.quarkus.hilla.BodyHandlerRecorder; import com.github.mcollovati.quarkus.hilla.HillaAtmosphereObjectFactory; +import com.github.mcollovati.quarkus.hilla.HillaConfiguration; import com.github.mcollovati.quarkus.hilla.HillaFormAuthenticationMechanism; import com.github.mcollovati.quarkus.hilla.HillaSecurityPolicy; import com.github.mcollovati.quarkus.hilla.HillaSecurityRecorder; @@ -93,7 +96,8 @@ import com.github.mcollovati.quarkus.hilla.QuarkusNavigationAccessControl; import com.github.mcollovati.quarkus.hilla.QuarkusVaadinServiceListenerPropagator; import com.github.mcollovati.quarkus.hilla.crud.FilterableRepositorySupport; -import com.github.mcollovati.quarkus.hilla.deployment.asm.SpringReplacer; +import com.github.mcollovati.quarkus.hilla.deployment.asm.OffendingMethodCallsReplacer; +import com.github.mcollovati.quarkus.hilla.reload.HillaLiveReloadRecorder; import com.github.mcollovati.quarkus.hilla.graal.DelayedInitBroadcaster; class QuarkusHillaExtensionProcessor { @@ -205,6 +209,17 @@ void installRequestBodyHandler( } } + @BuildStep(onlyIf = IsDevelopment.class) + @Record(value = ExecutionTime.RUNTIME_INIT) + void setupEndpointLiveReload( + LiveReloadBuildItem liveReloadBuildItem, + HillaConfiguration hillaConfiguration, + HillaLiveReloadRecorder recorder) { + if (hillaConfiguration.liveReload().enable()) { + recorder.startEndpointWatcher(liveReloadBuildItem.isLiveReload(), hillaConfiguration); + } + } + // EndpointsValidator checks for the presence of Spring, so it should be // ignored @BuildStep @@ -292,8 +307,8 @@ void registerHillaPushServlet(BuildProducer servletProducer, N } @BuildStep - void replaceCallsToSpring(BuildProducer producer) { - SpringReplacer.addClassVisitors(producer); + void replaceOffendingMethodCalls(BuildProducer producer) { + OffendingMethodCallsReplacer.addClassVisitors(producer); } @BuildStep diff --git a/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/SpringReplacer.java b/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java similarity index 92% rename from deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/SpringReplacer.java rename to deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java index 771b020d..0f6871d7 100644 --- a/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/SpringReplacer.java +++ b/deployment-commons/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java @@ -21,6 +21,7 @@ import com.vaadin.hilla.EndpointInvoker; import com.vaadin.hilla.EndpointRegistry; import com.vaadin.hilla.EndpointUtil; +import com.vaadin.hilla.Hotswapper; import com.vaadin.hilla.parser.utils.ConfigList; import com.vaadin.hilla.push.PushEndpoint; import com.vaadin.hilla.push.PushMessageHandler; @@ -30,11 +31,15 @@ import com.github.mcollovati.quarkus.hilla.SpringReplacements; -public class SpringReplacer { +public class OffendingMethodCallsReplacer { private static Map.Entry ClassUtils_getUserClass = Map.entry( MethodSignature.of("org/springframework/util/ClassUtils", "getUserClass"), MethodSignature.of(SpringReplacements.class, "classUtils_getUserClass")); + private static Map.Entry Class_forName = Map.entry( + MethodSignature.of(Class.class, "forName", "(Ljava/lang/String;)Ljava/lang/Class;"), + MethodSignature.of(SpringReplacements.class, "class_forName")); + private static Map.Entry AuthenticationUtil_getSecurityHolderAuthentication = Map.entry( MethodSignature.of( @@ -61,6 +66,7 @@ public class SpringReplacer { MethodSignature.of(SpringReplacements.class, "endpointInvoker_createDefaultEndpointMapper")); public static void addClassVisitors(BuildProducer producer) { + producer.produce(transform(Hotswapper.class, "affectsEndpoints", Class_forName)); producer.produce(transform(EndpointRegistry.class, "registerEndpoint", ClassUtils_getUserClass)); producer.produce(transform(EndpointUtil.class, "isAnonymousEndpoint", ClassUtils_getUserClass)); producer.produce(transform(EndpointInvoker.class, "checkAccess", ClassUtils_getUserClass)); diff --git a/integration-tests/react-smoke-test/src/main/java/com/example/application/HelloWorldService.java b/integration-tests/react-smoke-test/src/main/java/com/example/application/HelloWorldService.java index 79ddd83b..53f721e7 100644 --- a/integration-tests/react-smoke-test/src/main/java/com/example/application/HelloWorldService.java +++ b/integration-tests/react-smoke-test/src/main/java/com/example/application/HelloWorldService.java @@ -32,4 +32,8 @@ public String sayHello(String name) { return "Hello " + name; } } + + public int sum(int a, int b) { + return a + b; + } } diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/HillaConfiguration.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/HillaConfiguration.java new file mode 100644 index 00000000..db36b3a9 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/HillaConfiguration.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * Hilla configuration. + */ +@ConfigMapping(prefix = "vaadin.hilla") +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public interface HillaConfiguration { + + /** + * Configuration properties for endpoints live reload. + * + * @return configuration properties for endpoints live reload. + */ + LiveReloadConfig liveReload(); + + /** + * Configuration properties for endpoints hot reload. + *

+ * The extension watches source folders for changes in Java files and triggers a live reload if affected classes are Hilla endpoints or used in endpoints. + */ + interface LiveReloadConfig { + + /** + * Enabled endpoints live reload. + * @return {@literal true} if live reload is enabled, otherwise {@literal false} + */ + @WithDefault("true") + boolean enable(); + + /** + * The list of paths to watch for changes, relative to a root folder. + *

+ * For example, given a SOURCE {@link #watchStrategy()} and Maven project with source code in the default {@literal src/main/java} folder and + * endpoints related classes in {@literal src/main/java/com/example/service} and {@literal src/main/java/com/example/model}, + * the configuration should be {@literal vaadin.hilla.live-reload.watchedSourcePaths=com/example/service,com/example/model}. + *

+ * By default, all sub folders are watched. + * + * @return the list of paths to watch for changes. + */ + Optional> watchedPaths(); + + /** + * The strategy to use to watch for changes in Hilla endpoints. + *

+ * @return the strategy to use to watch for changes in Hilla endpoints. + */ + @WithDefault("CLASS") + WatchStrategy watchStrategy(); + + /** + * The strategy to use to watch for changes in Hilla endpoints. + */ + enum WatchStrategy { + /** + * Watch for changes in source files + */ + SOURCE, + /** + * Watch for changes in compiled classes. + *

+ * Best to be used in combination with {@literal quarkus.live-reload.instrumentation=true} to prevent excessive server restarts. + */ + CLASS + } + } +} diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java index 30871950..6731c876 100644 --- a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java @@ -32,6 +32,7 @@ import com.vaadin.flow.server.VaadinServletContext; import com.vaadin.flow.server.auth.AccessAnnotationChecker; import com.vaadin.flow.server.auth.NavigationAccessControl; +import com.vaadin.hilla.ApplicationContextProvider; import com.vaadin.hilla.EndpointCodeGenerator; import com.vaadin.hilla.EndpointController; import com.vaadin.hilla.EndpointInvoker; @@ -171,8 +172,17 @@ public ObjectMapper build() { @Produces @Singleton - ApplicationContext applicationContext(BeanManager beanManager) { - return new QuarkusApplicationContext(beanManager); + ApplicationContext applicationContext(BeanManager beanManager, ApplicationContextProvider appCtxProvider) { + QuarkusApplicationContext applicationContext = new QuarkusApplicationContext(beanManager); + appCtxProvider.setApplicationContext(applicationContext); + return applicationContext; + } + + @Produces + @Singleton + @DefaultBean + ApplicationContextProvider applicationContextProvider() { + return new ApplicationContextProvider(); } @Produces diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/SpringReplacements.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/SpringReplacements.java index d341ad12..23ca8d05 100644 --- a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/SpringReplacements.java +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/SpringReplacements.java @@ -66,4 +66,12 @@ private static SecurityIdentity currentIdentity() { public static ObjectMapper endpointInvoker_createDefaultEndpointMapper(ApplicationContext context) { return null; } + + public static Class class_forName(String className) throws ClassNotFoundException { + try { + return Class.forName(className, true, Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + return Class.forName(className); + } + } } diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/AbstractEndpointsWatcher.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/AbstractEndpointsWatcher.java new file mode 100644 index 00000000..b1ce2902 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/AbstractEndpointsWatcher.java @@ -0,0 +1,267 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.reload; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.vaadin.hilla.EndpointCodeGenerator; +import com.vaadin.hilla.Hotswapper; +import io.quarkus.dev.spi.HotReplacementContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; + +abstract class AbstractEndpointsWatcher implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractEndpointsWatcher.class); + + private final WatchService watchService; + private final HotReplacementContext context; + private final List rootPaths; + private final Set watchedPaths; + private final Map watchKeys = new HashMap<>(); + private volatile boolean running; + + AbstractEndpointsWatcher(HotReplacementContext context, List rootPaths, Set watchedPaths) + throws IOException { + this.context = context; + this.rootPaths = rootPaths; + this.watchedPaths = watchedPaths != null ? watchedPaths : Set.of(); + this.watchService = FileSystems.getDefault().newWatchService(); + rootPaths.forEach(root -> { + if (this.watchedPaths.isEmpty()) { + LOGGER.debug("Watching for changes in folder {}", root); + } else { + LOGGER.debug("Watching for changes in folder {} sub-trees {}", root, watchedPaths); + } + this.registerRecursive(root); + }); + } + + private void registerRecursive(final Path root) { + try { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (!watchKeys.containsKey(dir)) { + watchKeys.put(dir, dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY)); + LOGGER.trace("Registering path {} for endpoint code changes", dir); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private void unregisterRecursive(final Path root) { + Set removedPaths = watchKeys.keySet().stream() + .filter(path -> path.equals(root) || path.startsWith(root)) + .collect(Collectors.toSet()); + for (Path path : removedPaths) { + watchKeys.remove(path).cancel(); + } + removedPaths.stream().sorted().forEach(path -> LOGGER.trace("Unregistered path {}", path)); + } + + @Override + public void run() { + running = true; + LOGGER.debug("Starting endpoints changes watcher"); + WatchKey key; + try { + while (!Thread.currentThread().isInterrupted() && running && (key = watchService.take()) != null) { + Map changedFiles = computeChangedSources(key); + Set changedClasses = changedFiles.values().stream() + .filter(className -> !className.isEmpty()) + .collect(Collectors.toSet()); + boolean requiresHotswap = false; + if (!changedFiles.isEmpty()) { + LOGGER.trace("Searching for endpoints related class in changed files {}", changedFiles.keySet()); + Set usedClasses = null; + try { + usedClasses = EndpointCodeGenerator.getInstance() + .getClassesUsedInOpenApi() + .orElse(Set.of()); + } catch (Exception ex) { + LOGGER.debug("Cannot get used classes from Open API. Force scan for changes", ex); + } + try { + if (usedClasses == null) { + // Force a scan if we cannot get the list of changed classes + // This might regenerate and fix a potential invalid Open API file + requiresHotswap = !context.doScan(false); + } else if (changedClasses.stream().anyMatch(usedClasses::contains)) { + LOGGER.debug( + "At least one of the changed classes [{}] is used in an endpoint", changedClasses); + requiresHotswap = !context.doScan(false); + } else { + for (var pair : changedFiles.entrySet()) { + Path classFile = pair.getKey(); + if (fileContainsEndpointUsedClasses(classFile, usedClasses)) { + requiresHotswap = !context.doScan(false); + break; + } + } + } + if (requiresHotswap) { + LOGGER.debug( + "Server not restarted because classes replaced via instrumentation. Forcing Hilla hotswap. {}", + Thread.currentThread().getContextClassLoader()); + Hotswapper.onHotswap(true, changedFiles.values().toArray(new String[0])); + } + } catch (Exception ex) { + LOGGER.debug("Endpoint live reload failed", ex); + } + } + key.reset(); + } + } catch (InterruptedException e) { + stop(); + Thread.currentThread().interrupt(); + } catch (Exception exception) { + LOGGER.error("Unrecoverable error. Endpoint changes watcher will be stopped", exception); + } + LOGGER.debug("Stopped endpoints changes watcher"); + } + + private Map computeChangedSources(WatchKey key) { + Set processedPaths = new HashSet<>(); + Map changedClasses = new HashMap<>(); + List> events; + List> allEvents = new ArrayList<>(); + // Try to collect all close events, to prevent multiple reloads + do { + events = key.pollEvents(); + allEvents.addAll(events); + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // ignore + } + } while (!events.isEmpty()); + + for (WatchEvent event : allEvents) { + Path affectedRelativePath = (Path) event.context(); + WatchEvent.Kind eventKind = event.kind(); + Path parentPath = (Path) key.watchable(); + LOGGER.trace("Event {} on file {} (happened {} time(s)).", eventKind, event.context(), event.count()); + Path affectedPath = parentPath.resolve(affectedRelativePath); + boolean isDirectory = Files.isDirectory(affectedPath) || watchKeys.containsKey(affectedPath); + boolean isAddedOrChanged = eventKind == ENTRY_CREATE || eventKind == ENTRY_MODIFY; + if (isDirectory) { + if (eventKind == ENTRY_CREATE) { + LOGGER.debug("New directory: {}", affectedRelativePath); + registerRecursive(affectedPath); + } else if (eventKind == ENTRY_DELETE) { + LOGGER.debug("Directory removed: {}", affectedRelativePath); + unregisterRecursive(affectedPath); + } + } else if (!processedPaths.contains(affectedRelativePath) + && isAddedOrChanged + && isPotentialEndpointRelatedFile(affectedPath)) { + processedPaths.add(affectedRelativePath); + LOGGER.trace("Java source file {} changed ({})", affectedRelativePath, eventKind.name()); + rootPaths.stream() + .filter(dir -> + affectedPath.startsWith(dir.toAbsolutePath().toString())) + .findFirst() + .filter(dir -> isWatchedPath(dir, affectedPath)) + .ifPresent( + classPath -> changedClasses.computeIfAbsent(classPath.resolve(affectedPath), file -> { + String className = deriveClassName(classPath.relativize(file)) + .orElse(""); + if (!className.isEmpty()) { + LOGGER.trace( + "Computed Java class name {} for file {}", + className, + affectedRelativePath); + } + return className; + })); + } + } + return changedClasses; + } + + /** + * Gets if the given file should be inspected for potential Hilla endpoint related components. + * + * @param file the file to inspect + * @return {@literal true} if the file should be inspected, otherwise {@literal false}. + */ + protected abstract boolean isPotentialEndpointRelatedFile(Path file); + + /** + * Tries to derive a top level class name from the given file. + * @param relativePath the file to inspect. + * @return the top level classname for the give file, or an empty optional, never {@literal null}. + */ + protected abstract Optional deriveClassName(Path relativePath); + + /** + * Inspect the content of the give file to determine if it contains any type (class, record, interface, ...) used in any Hilla endpoint. + * @param classFile the file to inspect + * @param classesUsedInEndpoints a set of class names currently used by Hilla endpoints. + * @return {@literal true} if the file relates to a type used in Hilla endpoints, otherwise {@literal false}. + */ + protected abstract boolean fileContainsEndpointUsedClasses(Path classFile, Set classesUsedInEndpoints); + + private boolean isWatchedPath(Path rootPath, Path relativePath) { + if (watchedPaths.isEmpty()) { + return true; + } + Path relativeToSourceRoot = rootPath.relativize(relativePath); + if (watchedPaths.stream().anyMatch(relativeToSourceRoot::startsWith)) { + LOGGER.trace("{} is in a watched path", relativeToSourceRoot); + return true; + } + LOGGER.trace("Ignoring changes to {} because it is not in a watched path", relativeToSourceRoot); + return false; + } + + void stop() { + try { + running = false; + watchService.close(); + } catch (IOException e) { + LOGGER.debug("Failure happen stopping endpoints source code watcher", e); + } + } +} diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java new file mode 100644 index 00000000..753c0a51 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.reload; + +import java.io.IOException; +import java.lang.instrument.ClassDefinition; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; + +import com.vaadin.hilla.BrowserCallable; +import com.vaadin.hilla.Endpoint; +import com.vaadin.hilla.EndpointExposed; +import io.quarkus.dev.spi.HotReplacementContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class EndpointClassesWatcher extends AbstractEndpointsWatcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(EndpointClassesWatcher.class); + + EndpointClassesWatcher(HotReplacementContext context, Set watchedPaths) throws IOException { + super(context, context.getClassesDir(), watchedPaths); + } + + @Override + protected boolean isPotentialEndpointRelatedFile(Path file) { + return file.toFile().getName().endsWith(".class"); + } + + @Override + protected Optional deriveClassName(Path relativePath) { + String className = + relativePath.toString().replace(relativePath.getFileSystem().getSeparator(), "."); + className = className.substring(0, className.length() - ".class".length()); + return Optional.of(className); + } + + @Override + protected boolean fileContainsEndpointUsedClasses(Path classFile, Set classesUsedInEndpoints) { + String className = deriveClassName(classFile).orElse(null); + if (className != null) { + try { + ClassDefinition definition = new ClassDefinition( + Thread.currentThread().getContextClassLoader().loadClass(className), + Files.readAllBytes(classFile)); + Class definitionClass = definition.getDefinitionClass(); + if (definitionClass.isAnnotationPresent(BrowserCallable.class) + || definitionClass.isAnnotationPresent(Endpoint.class) + || definitionClass.isAnnotationPresent(EndpointExposed.class)) { + LOGGER.debug("The changed class {} has an endpoint annotation", className); + return true; + } + } catch (Exception ex) { + LOGGER.debug("Cannot load changed class {} from file {}", className, classFile); + } + } + return false; + } +} diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointSourcesWatcher.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointSourcesWatcher.java new file mode 100644 index 00000000..8e3540bf --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointSourcesWatcher.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.reload; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.visitor.NodeFinderVisitor; +import com.vaadin.hilla.BrowserCallable; +import com.vaadin.hilla.Endpoint; +import com.vaadin.hilla.EndpointExposed; +import io.quarkus.dev.spi.HotReplacementContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class EndpointSourcesWatcher extends AbstractEndpointsWatcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(EndpointSourcesWatcher.class); + + EndpointSourcesWatcher(HotReplacementContext context, Set watchedPaths) throws IOException { + super(context, context.getSourcesDir(), watchedPaths); + } + + @Override + protected boolean isPotentialEndpointRelatedFile(Path file) { + return file.toFile().getName().endsWith(".java"); + } + + @Override + protected Optional deriveClassName(Path relativePath) { + String className = + relativePath.toString().replace(relativePath.getFileSystem().getSeparator(), "."); + className = className.substring(0, className.length() - ".java".length()); + return Optional.of(className); + } + + protected boolean fileContainsEndpointUsedClasses(Path classFile, Set classesUsedInEndpoints) { + ParseResult parseResult; + try { + parseResult = new JavaParser( + new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)) + .parse(classFile.toAbsolutePath()); + } catch (IOException e) { + LOGGER.debug("Skipping unparsable Java file {}.", classFile, e); + return false; + } + TypeDeclaration usedType = parseResult + .getResult() + .map(unit -> { + NodeFinderVisitor visitor = + new NodeFinderVisitor(((node, range) -> node instanceof TypeDeclaration typeDeclaration + && (getFullyQualifiedName(typeDeclaration) + .map(classesUsedInEndpoints::contains) + .orElse(false) + || typeDeclaration.isAnnotationPresent(BrowserCallable.class) + || typeDeclaration.isAnnotationPresent(Endpoint.class) + || typeDeclaration.isAnnotationPresent(EndpointExposed.class)))); + unit.accept(visitor, null); + return (TypeDeclaration) visitor.getSelectedNode(); + }) + .orElse(null); + if (usedType != null) { + LOGGER.debug( + "At least one class [{}] in the changed source file {} is used in an endpoint", + usedType.getNameAsString(), + classFile); + } + return usedType != null; + } + + @SuppressWarnings("unchecked") + private static Optional getFullyQualifiedName(TypeDeclaration type) { + if (type.isTopLevelType()) { + return type.getFullyQualifiedName(); + } + return type.findAncestor(TypeDeclaration.class) + .map(td -> (TypeDeclaration) td) + .flatMap(td -> td.getFullyQualifiedName().map(fqn -> fqn + "$" + type.getNameAsString())); + } +} diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointsHotReplacementSetup.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointsHotReplacementSetup.java new file mode 100644 index 00000000..802c91b3 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointsHotReplacementSetup.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.reload; + +import io.quarkus.dev.spi.HotReplacementContext; +import io.quarkus.dev.spi.HotReplacementSetup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EndpointsHotReplacementSetup implements HotReplacementSetup { + + private static final Logger LOGGER = LoggerFactory.getLogger(EndpointsHotReplacementSetup.class); + + @Override + public void setupHotDeployment(HotReplacementContext context) { + LOGGER.debug("Setup Hilla live reload"); + HillaLiveReloadRecorder.setHotReplacement(context); + } + + @Override + public void close() { + HillaLiveReloadRecorder.closeEndpointWatcher(); + } +} diff --git a/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/HillaLiveReloadRecorder.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/HillaLiveReloadRecorder.java new file mode 100644 index 00000000..9e23caa7 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/HillaLiveReloadRecorder.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024 Marco Collovati, Dario Götze + * + * 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.reload; + +import java.io.IOException; +import java.util.Set; + +import io.quarkus.dev.spi.HotReplacementContext; +import io.quarkus.runtime.annotations.Recorder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.mcollovati.quarkus.hilla.HillaConfiguration; + +@Recorder +public class HillaLiveReloadRecorder { + + private static final Logger LOGGER = LoggerFactory.getLogger(HillaLiveReloadRecorder.class); + + private static volatile HotReplacementContext hotReplacementContext; + private static AbstractEndpointsWatcher endpointSourcesWatcher; + + public static void setHotReplacement(HotReplacementContext context) { + hotReplacementContext = context; + } + + public void startEndpointWatcher(boolean liveReload, HillaConfiguration configuration) { + LOGGER.debug("{}tarting endpoint live reload watcher", liveReload ? "Re" : "S"); + if (liveReload) { + stopEndpointWatcher(); + } + + try { + if (configuration.liveReload().watchStrategy() + == HillaConfiguration.LiveReloadConfig.WatchStrategy.SOURCE) { + endpointSourcesWatcher = new EndpointSourcesWatcher( + hotReplacementContext, + configuration.liveReload().watchedPaths().orElse(Set.of())); + } else { + endpointSourcesWatcher = new EndpointClassesWatcher( + hotReplacementContext, + configuration.liveReload().watchedPaths().orElse(Set.of())); + } + new Thread(endpointSourcesWatcher).start(); + } catch (IOException e) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Cannot start endpoint watcher", e); + } else { + LOGGER.warn("Cannot start endpoint watcher: {}", e.getMessage()); + } + } + } + + public static void stopEndpointWatcher() { + if (endpointSourcesWatcher != null) { + endpointSourcesWatcher.stop(); + } + endpointSourcesWatcher = null; + } + + public static void closeEndpointWatcher() { + stopEndpointWatcher(); + hotReplacementContext = null; + } +} diff --git a/runtime-commons/src/main/resources/META-INF/services/io.quarkus.dev.spi.HotReplacementSetup b/runtime-commons/src/main/resources/META-INF/services/io.quarkus.dev.spi.HotReplacementSetup new file mode 100644 index 00000000..7113c5b6 --- /dev/null +++ b/runtime-commons/src/main/resources/META-INF/services/io.quarkus.dev.spi.HotReplacementSetup @@ -0,0 +1,18 @@ +# +# Copyright 2024 Marco Collovati, Dario Götze +# +# 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 +# +# http://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. +# +# + +com.github.mcollovati.quarkus.hilla.reload.EndpointsHotReplacementSetup