diff --git a/plugin/hotswap-agent-vaadin-plugin/src/main/java/org/hotswap/agent/plugin/vaadin/VaadinPlugin.java b/plugin/hotswap-agent-vaadin-plugin/src/main/java/org/hotswap/agent/plugin/vaadin/VaadinPlugin.java index d2dd5672..5c28b960 100644 --- a/plugin/hotswap-agent-vaadin-plugin/src/main/java/org/hotswap/agent/plugin/vaadin/VaadinPlugin.java +++ b/plugin/hotswap-agent-vaadin-plugin/src/main/java/org/hotswap/agent/plugin/vaadin/VaadinPlugin.java @@ -20,27 +20,42 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; import org.hotswap.agent.annotation.FileEvent; import org.hotswap.agent.annotation.Init; import org.hotswap.agent.annotation.LoadEvent; import org.hotswap.agent.annotation.OnClassFileEvent; import org.hotswap.agent.annotation.OnClassLoadEvent; +import org.hotswap.agent.annotation.OnResourceFileEvent; import org.hotswap.agent.annotation.Plugin; import org.hotswap.agent.command.ReflectionCommand; import org.hotswap.agent.command.Scheduler; import org.hotswap.agent.config.PluginConfiguration; import org.hotswap.agent.javassist.CannotCompileException; +import org.hotswap.agent.javassist.ClassPool; import org.hotswap.agent.javassist.CtClass; import org.hotswap.agent.javassist.NotFoundException; import org.hotswap.agent.logging.AgentLogger; import org.hotswap.agent.util.PluginManagerInvoker; +import static java.util.stream.Collectors.toCollection; + +import static org.hotswap.agent.annotation.FileEvent.CREATE; +import static org.hotswap.agent.annotation.FileEvent.DELETE; +import static org.hotswap.agent.annotation.FileEvent.MODIFY; +import static org.hotswap.agent.annotation.LoadEvent.REDEFINE; + /** * Vaadin 14.0+ plugin for HotswapAgent. - * + *

* https://vaadin.com * * @author Artur Signell @@ -53,6 +68,10 @@ expectedVersions = {"23 - 24"}) public class VaadinPlugin { + static final String VAADIN_SERVLET = "com.vaadin.flow.server.VaadinServlet"; + static final String VAADIN_SERVICE = "com.vaadin.flow.server.VaadinService"; + + @Init Scheduler scheduler; @@ -62,19 +81,28 @@ public class VaadinPlugin { @Init PluginConfiguration pluginConfiguration; - private UpdateRoutesCommand updateRouteRegistryCommand; + private boolean hasVaadinHotSwapper; - private ReflectionCommand reloadCommand; + // --> Vaadin [24.5,) + private final Set watchedPackages = new HashSet<>(); + private Object vaadinHotswapperObj; + private ResourceChangedCommand resourceChangedCommand; + // <-- Vaadin [24.5,) + // --> Vaadin [23, 24.5) + private UpdateRoutesCommand updateRouteRegistryCommand; + private ReflectionCommand reloadCommand; private ReflectionCommand clearReflectionCache = new ReflectionCommand(this, "com.vaadin.flow.internal.ReflectionCache", "clearAll"); - private Set> addedClasses = new HashSet<>(); - private Set> modifiedClasses = new HashSet<>(); + // <-- Vaadin [23, 24.5) + + private boolean pluginReady; private static final AgentLogger LOGGER = AgentLogger.getLogger(VaadinPlugin.class); + private static final String WATCHED_PACKAGES_PARAMETER = "vaadin.watched-packages"; private static final String RELOAD_QUIET_TIME_PARAMETER = "vaadin.liveReloadQuietTime"; private static final int DEFAULT_RELOAD_QUIET_TIME = 1000; // ms @@ -84,18 +112,78 @@ public class VaadinPlugin { public VaadinPlugin() { } - @OnClassLoadEvent(classNameRegexp = "com.vaadin.flow.server.VaadinServlet") - public static void init(CtClass ctClass) + @Init + public void initPlugin() { + String watchedPackages = this.pluginConfiguration.getProperty(WATCHED_PACKAGES_PARAMETER); + if (watchedPackages != null) { + Stream.of(watchedPackages + .split("\\s*,\\s*")).map(String::trim).filter(pkg -> !pkg.isEmpty()) + .collect(toCollection(() -> this.watchedPackages)); + LOGGER.info("Packages watched for class changes: {}", String.join(", ", this.watchedPackages)); + } + + reloadQuietTime = DEFAULT_RELOAD_QUIET_TIME; + String reloadQuietTimeValue = pluginConfiguration.getProperty(RELOAD_QUIET_TIME_PARAMETER); + if (reloadQuietTimeValue != null) { + if (reloadQuietTimeValue.matches("[1-9][0-1]+")) { + reloadQuietTime = Integer.parseInt(reloadQuietTimeValue); + LOGGER.info("Live-reload quiet time is {} ms", reloadQuietTime); + } else { + LOGGER.error("Illegal value '{}' for parameter {}, using default of {} ms", + reloadQuietTimeValue, RELOAD_QUIET_TIME_PARAMETER, DEFAULT_RELOAD_QUIET_TIME); + } + } + + } + + @OnClassLoadEvent(classNameRegexp = VAADIN_SERVLET) + public static void init(CtClass ctClass, ClassPool classPool) throws NotFoundException, CannotCompileException { + boolean hasVaadinHotSwapper = classPool.getOrNull("com.vaadin.flow.hotswap.Hotswapper") != null; + String src = PluginManagerInvoker .buildInitializePlugin(VaadinPlugin.class); - src += PluginManagerInvoker.buildCallPluginMethod(VaadinPlugin.class, - "registerServlet", "this", Object.class.getName()); + if (!hasVaadinHotSwapper) { + src += PluginManagerInvoker.buildCallPluginMethod(VaadinPlugin.class, + "registerServlet", "this", Object.class.getName()); + } ctClass.getDeclaredConstructor(new CtClass[0]).insertAfter(src); LOGGER.info("Initialized Vaadin plugin"); } + // Inject Vaadin Hotswapper initialization in VaadinService + @OnClassLoadEvent(classNameRegexp = VAADIN_SERVICE) + public static void transformVaadinService(CtClass ctClass, ClassPool classPool) + throws NotFoundException, CannotCompileException { + boolean hasVaadinHotSwapper = classPool.getOrNull("com.vaadin.flow.hotswap.Hotswapper") != null; + + StringBuilder src = new StringBuilder(); + if (hasVaadinHotSwapper) { + String initHotswapperPluginCall = PluginManagerInvoker.buildCallPluginMethod( + VaadinPlugin.class, "initializeHotswapper", "hotswapper", + "java.lang.Object"); + src.append("try { ") + .append("java.util.Optional maybeHotswapper = com.vaadin.flow.hotswap.Hotswapper.register(this);") + .append("if (maybeHotswapper.isPresent()) { ") + .append("Object hotswapper = maybeHotswapper.get();") + .append(initHotswapperPluginCall) + .append("} } catch (Exception e) { ") + .append("e.printStackTrace(); }"); // TODO: ignore exception + } else { + src.append(PluginManagerInvoker.buildCallPluginMethod( + VaadinPlugin.class, "vaadinServiceInitialized")); + } + ctClass.getDeclaredMethod("init").insertBefore(src.toString()); + + ctClass.getDeclaredMethod("destroy").insertBefore(PluginManagerInvoker.buildCallPluginMethod( + VaadinPlugin.class, "vaadinServiceDestroyed")); + + LOGGER.debug("{} has been enhanced.{}", VAADIN_SERVICE, hasVaadinHotSwapper ? "Vaadin Hotswapper registered" : ""); + + + } + public void registerServlet(Object vaadinServlet) { try { Class vaadinIntegrationClass = resolveClass("org.hotswap.agent.plugin.vaadin.VaadinIntegration"); @@ -109,51 +197,104 @@ public void registerServlet(Object vaadinServlet) { updateRouteRegistryCommand = new UpdateRoutesCommand(vaadinIntegration); reloadCommand = new ReflectionCommand(vaadinIntegration, "reload"); } catch (ClassNotFoundException | NoSuchMethodException - | InstantiationException | IllegalAccessException - | InvocationTargetException ex) { + | InstantiationException | IllegalAccessException + | InvocationTargetException ex) { LOGGER.error(null, ex); } } - @OnClassLoadEvent(classNameRegexp = ".*", events = LoadEvent.REDEFINE) - public void invalidateReflectionCache(CtClass ctClass) throws Exception { - LOGGER.debug("Redefined class {}, clearing Vaadin reflection cache and reloading browser", ctClass.getName()); - scheduler.scheduleCommand(clearReflectionCache); - scheduler.scheduleCommand(reloadCommand, getReloadQuietTime()); + public void initializeHotswapper(Object vaadinHotswapperObj) { + LOGGER.trace("Obtained Vaadin Hotswapper instance: {}", vaadinHotswapperObj); + this.vaadinHotswapperObj = vaadinHotswapperObj; + this.resourceChangedCommand = new ResourceChangedCommand( + vaadinHotswapperObj); + this.pluginReady = true; + + // Vaadin 24.5+, can nullify unused commands + this.updateRouteRegistryCommand = null; + this.reloadCommand = null; + this.clearReflectionCache = null; + this.addedClasses = null; + this.modifiedClasses = null; } - @OnClassFileEvent(classNameRegexp = ".*", events = { FileEvent.CREATE, FileEvent.MODIFY }) - public void classCreated(FileEvent eventType, CtClass ctClass) throws Exception { - if (FileEvent.CREATE.equals(eventType)) { - LOGGER.debug("Create class file event for " + ctClass.getName()); - addedClasses.add(resolveClass(ctClass.getName())); - } else if (FileEvent.MODIFY.equals(eventType)) { - LOGGER.debug("Modify class file event for " + ctClass.getName()); - modifiedClasses.add(resolveClass(ctClass.getName())); + public void vaadinServiceInitialized() { + pluginReady = true; + } + + public void vaadinServiceDestroyed() { + pluginReady = false; + } + + private boolean isVaadin_24_5_orNewer() { + return vaadinHotswapperObj != null; + } + + private boolean isPluginReady(String message) { + if (!pluginReady && LOGGER.isLevelEnabled(AgentLogger.Level.TRACE)) { + LOGGER.trace("Plugin not ready. {}", message); } - // Note that scheduling multiple calls to the same command postpones it - scheduler.scheduleCommand(updateRouteRegistryCommand); + return pluginReady; } - private Class resolveClass(String name) throws ClassNotFoundException { - return Class.forName(name, true, appClassLoader); + private boolean isWatchedPackage(String packageName) { + return watchedPackages.isEmpty() || watchedPackages.stream().anyMatch(packageName::startsWith); + } + + @OnClassLoadEvent(classNameRegexp = ".*", events = {LoadEvent.DEFINE, LoadEvent.REDEFINE}) + public void onClassLoadEvent(LoadEvent event, CtClass ctClass) throws Exception { + String className = ctClass.getName(); + if (!isPluginReady(event + " for class " + className)) { + return; + } + if (!isWatchedPackage(ctClass.getPackageName())) { + LOGGER.trace("Ignoring class {} because it is not in the watched-packages list", className); + return; + } + if (isVaadin_24_5_orNewer()) { + LOGGER.debug("Reloading class {} because of {}", className, + event); + ReflectionCommand command = new ReflectionCommand(vaadinHotswapperObj, + "onHotswap", new String[]{className}, + event == REDEFINE); + scheduler.scheduleCommand(command, reloadQuietTime); + } else if (event == LoadEvent.REDEFINE) { + LOGGER.debug("Redefined class {}, clearing Vaadin reflection cache and reloading browser", className); + scheduler.scheduleCommand(clearReflectionCache); + scheduler.scheduleCommand(reloadCommand, reloadQuietTime); + } } - private int getReloadQuietTime() { - if (reloadQuietTime <= 0) { - reloadQuietTime = DEFAULT_RELOAD_QUIET_TIME; - String reloadQuietTimeValue = pluginConfiguration.getProperty(RELOAD_QUIET_TIME_PARAMETER); - if (reloadQuietTimeValue != null) { - if (reloadQuietTimeValue.matches("[1-9][0-1]+")) { - reloadQuietTime = Integer.parseInt(reloadQuietTimeValue); - LOGGER.info("Live-reload quiet time is {} ms", reloadQuietTime); - } else { - LOGGER.error("Illegal value '{}' for parameter {}, using default of {} ms", - reloadQuietTimeValue, RELOAD_QUIET_TIME_PARAMETER, DEFAULT_RELOAD_QUIET_TIME); - } + @OnClassFileEvent(classNameRegexp = ".*", events = {FileEvent.CREATE, FileEvent.MODIFY}) + public void classCreated(FileEvent eventType, CtClass ctClass) throws Exception { + if (!isPluginReady(eventType + " for class " + ctClass.getName())) { + return; + } + if (!isVaadin_24_5_orNewer()) { + if (FileEvent.CREATE.equals(eventType)) { + LOGGER.debug("Create class file event for " + ctClass.getName()); + addedClasses.add(resolveClass(ctClass.getName())); + } else if (FileEvent.MODIFY.equals(eventType)) { + LOGGER.debug("Modify class file event for " + ctClass.getName()); + modifiedClasses.add(resolveClass(ctClass.getName())); } + // Note that scheduling multiple calls to the same command postpones it + scheduler.scheduleCommand(updateRouteRegistryCommand); + } + } + + @OnResourceFileEvent(path = "/") + public void resourceChanged(URI uri, FileEvent event) { + if (isVaadin_24_5_orNewer() && !uri.getPath().endsWith(".class") && isPluginReady(event + " for resource " + uri)) { + LOGGER.trace("Resource {} {}", uri, event); + resourceChangedCommand.registerEvent(event, uri); + scheduler.scheduleCommand(resourceChangedCommand, 200); } - return reloadQuietTime; + } + + + private Class resolveClass(String name) throws ClassNotFoundException { + return Class.forName(name, true, appClassLoader); } private class UpdateRoutesCommand extends ReflectionCommand { @@ -183,4 +324,41 @@ public void executeCommand() { modifiedClasses.clear(); } } + + private static class ResourceChangedCommand extends ReflectionCommand { + + Map> changedFiles = new ConcurrentHashMap<>(); + + public ResourceChangedCommand(Object hotswapper) { + super(hotswapper, "onHotswap"); + changedFiles.put(CREATE, new ArrayList<>()); + changedFiles.put(MODIFY, new ArrayList<>()); + changedFiles.put(DELETE, new ArrayList<>()); + } + + @Override + public List getParams() { + List modifiedFiles = changedFiles.get(MODIFY); + changedFiles.get(CREATE).removeIf(modifiedFiles::contains); + changedFiles.get(DELETE).removeIf(modifiedFiles::contains); + List parameters = new ArrayList<>(3); + parameters.add(changedFiles.get(CREATE).toArray(new URI[0])); + parameters.add(modifiedFiles.toArray(new URI[0])); + parameters.add(changedFiles.get(DELETE).toArray(new URI[0])); + return parameters; + } + + @Override + public void executeCommand() { + super.executeCommand(); + changedFiles.values().forEach(List::clear); + } + + void registerEvent(FileEvent event, URI uri) { + changedFiles.computeIfAbsent(event, k -> new ArrayList<>()) + .add(uri); + } + + } + }