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 b7f88111..58c779c2 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 @@ -47,6 +47,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; @@ -60,6 +61,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.builditem.CurateOutcomeBuildItem; import io.quarkus.undertow.deployment.IgnoredServletContainerInitializerBuildItem; @@ -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; @@ -92,7 +95,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; class QuarkusHillaExtensionProcessor { @@ -203,6 +207,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 @@ -283,8 +298,8 @@ void registerHillaPushServlet( } @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 997f2abf..03a85874 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( @@ -57,6 +62,7 @@ public class SpringReplacer { MethodSignature.DROP_METHOD); 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..94909f63 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/HillaConfiguration.java @@ -0,0 +1,68 @@ +/* + * 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 source folder. + *

+ * For example, given a 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 source folders are watched. + * + * @return the list of source paths to watch for changes. + */ + Optional> watchedSourcePaths(); + } +} 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 d24e56cf..2c3cc5e8 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 @@ -31,6 +31,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; @@ -170,8 +171,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(); } private EndpointController endpointController; 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 c825520a..4fcea9e4 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 @@ -52,4 +52,12 @@ public static Function authenticationUtil_getSecurityHolderRole } return role -> role != null && identity.hasRole(role); } + + 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/EndpointClassesWatcher.java b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java new file mode 100644 index 00000000..76375bd5 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointClassesWatcher.java @@ -0,0 +1,220 @@ +/* + * 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.lang.instrument.ClassDefinition; +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.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.vaadin.hilla.BrowserCallable; +import com.vaadin.hilla.Endpoint; +import com.vaadin.hilla.EndpointCodeGenerator; +import com.vaadin.hilla.EndpointExposed; +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; + +class EndpointClassesWatcher implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(EndpointClassesWatcher.class); + + private final WatchService watchService; + private final HotReplacementContext context; + private final Map watchKeys = new HashMap<>(); + private volatile boolean running; + + EndpointClassesWatcher(HotReplacementContext context) throws IOException { + this.context = context; + this.watchService = FileSystems.getDefault().newWatchService(); + for (Path path : this.context.getClassesDir()) { + watchKeys.put(path, path.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY)); + registerRecursive(path); + } + } + + 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.info("============= Registered path " + 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.info("============= Unregistered path " + path)); + } + + @Override + public void run() { + running = true; + LOGGER.info( + "============================= START WATCHER :: {}", + Thread.currentThread().getContextClassLoader()); + WatchKey key; + try { + while (running && (key = watchService.take()) != null) { + Thread.sleep(1000); + Map changedClasses = new HashMap<>(); + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind eventKind = event.kind(); + LOGGER.info("================================================== EndpointClassesWatcher :: " + + "Event kind:" + + eventKind + " ( " + event.count() + ")" + + ". File affected: " + event.context() + "."); + LOGGER.info("================ "); + Path parentPath = (Path) key.watchable(); + if (event.context() instanceof Path affectedRelativePath) { + 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.info("============= Directory added " + affectedRelativePath); + registerRecursive(affectedPath); + } else if (eventKind == ENTRY_DELETE) { + LOGGER.info("============= Directory removed " + affectedRelativePath); + unregisterRecursive(affectedPath); + } + } else if (affectedPath.toFile().getName().endsWith(".class") && isAddedOrChanged) { + LOGGER.info("============= Class " + eventKind.name() + " ::" + affectedPath); + + context.getClassesDir().stream() + .filter(dir -> affectedPath.startsWith( + dir.toAbsolutePath().toString())) + .findFirst() + /* + .map(dir -> { + String className = dir.relativize(affectedPath) + .toString() + .replace(dir.getFileSystem().getSeparator(), "."); + className = className.substring(0, className.length() - ".class".length()); + return className; + }) + */ + .ifPresent(classPath -> { + changedClasses.computeIfAbsent(classPath, dir -> { + String className = dir.relativize(affectedPath) + .toString() + .replace(dir.getFileSystem().getSeparator(), "."); + return className.substring(0, className.length() - ".class".length()); + }); + }); + } + } + } + boolean requiresHotswap = false; + if (!changedClasses.isEmpty()) { + LOGGER.info("=================== Reloading"); + try { + Set usedClasses = EndpointCodeGenerator.getInstance() + .getClassesUsedInOpenApi() + .orElse(Set.of()); + if (changedClasses.values().stream().anyMatch(usedClasses::contains)) { + LOGGER.info( + "At least one of the changed classes [{}] is used in an endpoint", + changedClasses.values()); + LOGGER.info( + "============================= DO SCAN :: {}", + Thread.currentThread().getContextClassLoader()); + requiresHotswap = !context.doScan(false); + } else { + for (var pair : changedClasses.entrySet()) { + String className = pair.getValue(); + Path classFile = pair.getKey(); + 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.info("The changed class {} has an endpoint annotation", className); + requiresHotswap = !context.doScan(false); + break; + } + } catch (Exception e) { + LOGGER.debug("Cannot inspect class {} from file {}", className, classFile, e); + } + } + } + if (requiresHotswap) { + LOGGER.info("=========================== Hilla hotswap required"); + Hotswapper.onHotswap(true, changedClasses.values().toArray(new String[0])); + } + } catch (Exception ex) { + LOGGER.info("======================= HotSwap failed"); + ex.printStackTrace(); + } + } + // empty the queue + key.pollEvents(); + key.reset(); + } + } catch (InterruptedException e) { + stop(); + Thread.currentThread().interrupt(); + } catch (Exception exception) { + LOGGER.error("OOOOOOOOOOOOOOOOOOOOOOOOOOOOPS", exception); + } + LOGGER.info("============================= END WATCHER"); + } + + void stop() { + try { + running = false; + watchService.close(); + } catch (IOException e) { + LOGGER.info("===================== cannot stop watcher "); + e.printStackTrace(); + } + } +} 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..2689d3e2 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/EndpointSourcesWatcher.java @@ -0,0 +1,292 @@ +/* + * 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.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.EndpointCodeGenerator; +import com.vaadin.hilla.EndpointExposed; +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; + +class EndpointSourcesWatcher implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(EndpointSourcesWatcher.class); + + private final WatchService watchService; + private final HotReplacementContext context; + private final Set watchedPaths; + private final Map watchKeys = new HashMap<>(); + private volatile boolean running; + + EndpointSourcesWatcher(HotReplacementContext context, Set watchedPaths) throws IOException { + this.context = context; + this.watchedPaths = watchedPaths != null ? watchedPaths : Set.of(); + this.watchService = FileSystems.getDefault().newWatchService(); + this.context.getSourcesDir().forEach(root -> { + if (this.watchedPaths.isEmpty()) { + LOGGER.debug("Watching for changes in source folder {}", root); + } else { + LOGGER.debug("Watching for changes in source 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 source 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 source code watcher"); + WatchKey key; + try { + while (!Thread.currentThread().isInterrupted() && running && (key = watchService.take()) != null) { + Map changedClasses = computeChangedSources(key); + boolean requiresHotswap = false; + if (!changedClasses.isEmpty()) { + LOGGER.trace("Searching for endpoints related class in changed files {}", changedClasses.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.values().stream().anyMatch(usedClasses::contains)) { + LOGGER.debug( + "At least one of the changed classes [{}] is used in an endpoint", + changedClasses.values()); + requiresHotswap = !context.doScan(false); + } else { + for (var pair : changedClasses.entrySet()) { + Path classFile = pair.getKey(); + if (javaClassContainsEndpointUsedClasses(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, changedClasses.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. Watcher will be stopped", exception); + } + LOGGER.debug("Stopped endpoints source code 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) + && affectedPath.toFile().getName().endsWith(".java") + && isAddedOrChanged) { + processedPaths.add(affectedRelativePath); + LOGGER.trace("Java source file {} changed ({})", affectedRelativePath, eventKind.name()); + context.getSourcesDir().stream() + .filter(dir -> + affectedPath.startsWith(dir.toAbsolutePath().toString())) + .findFirst() + .filter(dir -> isWatchedPath(dir, affectedPath)) + .ifPresent(classPath -> changedClasses.computeIfAbsent(classPath.resolve(affectedPath), dir -> { + String className = classPath + .relativize(dir) + .toString() + .replace(dir.getFileSystem().getSeparator(), "."); + className = className.substring(0, className.length() - ".java".length()); + LOGGER.trace("Computed Java class name {} for file {}", className, affectedRelativePath); + return className; + })); + } + } + return changedClasses; + } + + private boolean isWatchedPath(Path sourceRoot, Path relativePath) { + if (watchedPaths.isEmpty()) { + return true; + } + Path relativeToSourceRoot = sourceRoot.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; + } + + private boolean javaClassContainsEndpointUsedClasses(Path classFile, Set usedClasses) { + if (!classFile.toFile().getName().endsWith(".java")) { + return false; + } + 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(usedClasses::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())); + } + + 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/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..87d1c003 --- /dev/null +++ b/runtime-commons/src/main/java/com/github/mcollovati/quarkus/hilla/reload/HillaLiveReloadRecorder.java @@ -0,0 +1,68 @@ +/* + * 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 EndpointSourcesWatcher 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 { + endpointSourcesWatcher = new EndpointSourcesWatcher( + hotReplacementContext, + configuration.liveReload().watchedSourcePaths().orElse(Set.of())); + new Thread(endpointSourcesWatcher).start(); + } catch (IOException e) { + LOGGER.info("===================== cannot start watcher"); + e.printStackTrace(); + } + } + + 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