diff --git a/pom.xml b/pom.xml index 7e816749732..4a20274893f 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,7 @@ spring-boot spring-boot-autoconfigure spring-boot-actuator + spring-boot-developer-tools spring-boot-docs spring-boot-starters spring-boot-cli diff --git a/spring-boot-dependencies/pom.xml b/spring-boot-dependencies/pom.xml index 0fa9def79b3..a30530e91cf 100644 --- a/spring-boot-dependencies/pom.xml +++ b/spring-boot-dependencies/pom.xml @@ -184,6 +184,11 @@ spring-boot-configuration-processor 1.3.0.BUILD-SNAPSHOT + + org.springframework.boot + spring-boot-developer-tools + 1.3.0.BUILD-SNAPSHOT + org.springframework.boot spring-boot-loader diff --git a/spring-boot-developer-tools/pom.xml b/spring-boot-developer-tools/pom.xml new file mode 100644 index 00000000000..31acb05aa41 --- /dev/null +++ b/spring-boot-developer-tools/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-parent + 1.3.0.BUILD-SNAPSHOT + ../spring-boot-parent + + spring-boot-developer-tools + Spring Boot Developer Tools + Spring Boot Developer Tools + http://projects.spring.io/spring-boot/ + + Pivotal Software, Inc. + http://www.spring.io + + + ${basedir}/.. + + + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework + spring-web + true + + + javax.servlet + javax.servlet-api + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework + spring-webmvc + test + + + org.apache.tomcat.embed + tomcat-embed-core + test + + + org.apache.tomcat.embed + tomcat-embed-logging-juli + test + + + org.eclipse.jetty.websocket + websocket-client + ${jetty.version} + test + + + org.springframework.boot + spring-boot-starter-thymeleaf + test + + + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + + + org.springframework.boot.developertools.tunnel.server.RemoteDebugPortProvider + + + + + + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteSpringApplication.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteSpringApplication.java new file mode 100644 index 00000000000..98b54a1c345 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteSpringApplication.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools; + +import org.springframework.boot.Banner; +import org.springframework.boot.ResourceBanner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.developertools.remote.client.RemoteClientConfiguration; +import org.springframework.boot.developertools.restart.RestartInitializer; +import org.springframework.boot.developertools.restart.Restarter; +import org.springframework.core.io.ClassPathResource; + +/** + * Application that can be used to establish a link to remotely running Spring Boot code. + * Allows remote debugging and remote updates (if enabled). This class should be launched + * from within your IDE and should have the same classpath configuration as the locally + * developed application. The remote URL of the application should be provided as a + * non-option argument. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RemoteClientConfiguration + */ +public class RemoteSpringApplication { + + private void run(String[] args) { + Restarter.initialize(args, RestartInitializer.NONE); + SpringApplication application = new SpringApplication( + RemoteClientConfiguration.class); + application.setWebEnvironment(false); + application.setBanner(getBanner()); + application.addListeners(new RemoteUrlPropertyExtractor()); + application.run(args); + waitIndefinitely(); + } + + private Banner getBanner() { + ClassPathResource banner = new ClassPathResource("remote-banner.txt", + RemoteSpringApplication.class); + return new ResourceBanner(banner); + } + + private void waitIndefinitely() { + while (true) { + try { + Thread.sleep(1000); + } + catch (InterruptedException ex) { + } + } + } + + /** + * Run the {@link RemoteSpringApplication}. + * @param args the program arguments (including the remote URL as a non-option + * argument) + */ + public static void main(String[] args) { + new RemoteSpringApplication().run(args); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteUrlPropertyExtractor.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteUrlPropertyExtractor.java new file mode 100644 index 00000000000..0741b445322 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/RemoteUrlPropertyExtractor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Map; + +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.env.CommandLinePropertySource; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link ApplicationListener} to extract the remote URL for the + * {@link RemoteSpringApplication} to use. + * + * @author Phillip Webb + */ +class RemoteUrlPropertyExtractor implements + ApplicationListener { + + private static final String NON_OPTION_ARGS = CommandLinePropertySource.DEFAULT_NON_OPTION_ARGS_PROPERTY_NAME; + + @Override + public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { + ConfigurableEnvironment environment = event.getEnvironment(); + String url = environment.getProperty(NON_OPTION_ARGS); + Assert.state(StringUtils.hasLength(url), "No remote URL specified"); + Assert.state(url.indexOf(",") == -1, "Multiple URLs specified"); + try { + new URI(url); + } + catch (URISyntaxException ex) { + throw new IllegalStateException("Malformed URL '" + url + "'"); + } + Map source = Collections.singletonMap("remoteUrl", (Object) url); + PropertySource propertySource = new MapPropertySource("remoteUrl", source); + environment.getPropertySources().addLast(propertySource); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java new file mode 100644 index 00000000000..6e2dd83415a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for developer tools. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@ConfigurationProperties(prefix = "spring.developertools") +public class DeveloperToolsProperties { + + private static final String DEFAULT_RESTART_EXCLUDES = "META-INF/resources/**,resource/**,static/**,public/**,templates/**"; + + private Restart restart = new Restart(); + + private Livereload livereload = new Livereload(); + + private RemoteDeveloperToolsProperties remote = new RemoteDeveloperToolsProperties(); + + public Restart getRestart() { + return this.restart; + } + + public Livereload getLivereload() { + return this.livereload; + } + + public RemoteDeveloperToolsProperties getRemote() { + return this.remote; + } + + /** + * Restart properties + */ + public static class Restart { + + /** + * Enable automatic restart. + */ + private boolean enabled = true; + + /** + * Patterns that should be excluding for triggering a full restart. + */ + private String exclude = DEFAULT_RESTART_EXCLUDES; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getExclude() { + return this.exclude; + } + + public void setExclude(String exclude) { + this.exclude = exclude; + } + + } + + /** + * LiveReload properties + */ + public static class Livereload { + + /** + * Enable a livereload.com compatible server. + */ + private boolean enabled = true; + + /** + * Server port. + */ + private int port = 35729; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getPort() { + return this.port; + } + + public void setPort(int port) { + this.port = port; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java new file mode 100644 index 00000000000..971be717c37 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; + +/** + * {@link BeanFactoryPostProcessor} to add properties that make sense when working + * locally. + * + * @author Phillip Webb + */ +class LocalDeveloperPropertyDefaultsPostProcessor implements BeanFactoryPostProcessor, + EnvironmentAware { + + private static final Map PROPERTIES; + static { + Map properties = new HashMap(); + properties.put("spring.thymeleaf.cache", "false"); + PROPERTIES = Collections.unmodifiableMap(properties); + } + + private Environment environment; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { + if (this.environment instanceof ConfigurableEnvironment) { + postProcessEnvironment((ConfigurableEnvironment) this.environment); + } + } + + private void postProcessEnvironment(ConfigurableEnvironment environment) { + PropertySource propertySource = new MapPropertySource("refresh", PROPERTIES); + environment.getPropertySources().addFirst(propertySource); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java new file mode 100644 index 00000000000..922389c3c3f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java @@ -0,0 +1,129 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +import java.net.URL; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; +import org.springframework.boot.developertools.classpath.ClassPathFileSystemWatcher; +import org.springframework.boot.developertools.classpath.ClassPathRestartStrategy; +import org.springframework.boot.developertools.classpath.PatternClassPathRestartStrategy; +import org.springframework.boot.developertools.livereload.LiveReloadServer; +import org.springframework.boot.developertools.restart.ConditionalOnInitializedRestarter; +import org.springframework.boot.developertools.restart.RestartScope; +import org.springframework.boot.developertools.restart.Restarter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for local development support. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@Configuration +@ConditionalOnInitializedRestarter +@EnableConfigurationProperties(DeveloperToolsProperties.class) +public class LocalDeveloperToolsAutoConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + public static LocalDeveloperPropertyDefaultsPostProcessor localDeveloperPropertyDefaultsPostProcessor() { + return new LocalDeveloperPropertyDefaultsPostProcessor(); + } + + /** + * Local LiveReload configuration. + */ + @ConditionalOnProperty(prefix = "spring.developertools.livereload", name = "enabled", matchIfMissing = true) + static class LiveReloadConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Autowired(required = false) + private LiveReloadServer liveReloadServer; + + @Bean + @RestartScope + @ConditionalOnMissingBean + public LiveReloadServer liveReloadServer() { + return new LiveReloadServer(this.properties.getLivereload().getPort(), + Restarter.getInstance().getThreadFactory()); + } + + @EventListener + public void onContextRefreshed(ContextRefreshedEvent event) { + optionalLiveReloadServer().triggerReload(); + } + + @EventListener + public void onClassPathChanged(ClassPathChangedEvent event) { + if (!event.isRestartRequired()) { + optionalLiveReloadServer().triggerReload(); + } + } + + @Bean + public OptionalLiveReloadServer optionalLiveReloadServer() { + return new OptionalLiveReloadServer(this.liveReloadServer); + } + + } + + /** + * Local Restart Configuration. + */ + @ConditionalOnProperty(prefix = "spring.developertools.restart", name = "enabled", matchIfMissing = true) + static class RestartConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + @ConditionalOnMissingBean + public ClassPathFileSystemWatcher classPathFileSystemWatcher() { + URL[] urls = Restarter.getInstance().getInitialUrls(); + return new ClassPathFileSystemWatcher(classPathRestartStrategy(), urls); + } + + @Bean + @ConditionalOnMissingBean + public ClassPathRestartStrategy classPathRestartStrategy() { + return new PatternClassPathRestartStrategy(this.properties.getRestart() + .getExclude()); + } + + @EventListener + public void onClassPathChanged(ClassPathChangedEvent event) { + if (event.isRestartRequired()) { + Restarter.getInstance().restart(); + } + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/OptionalLiveReloadServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/OptionalLiveReloadServer.java new file mode 100644 index 00000000000..a9f4ad25392 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/OptionalLiveReloadServer.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +import javax.annotation.PostConstruct; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.livereload.LiveReloadServer; + +/** + * Manages an optional {@link LiveReloadServer}. The {@link LiveReloadServer} may + * gracefully fail to start (e.g. because of a port conflict) or may be omitted entirely. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class OptionalLiveReloadServer { + + private static final Log logger = LogFactory.getLog(OptionalLiveReloadServer.class); + + private LiveReloadServer server; + + /** + * Create a new {@link OptionalLiveReloadServer} instance. + * @param server the server to manage or {@code null} + */ + public OptionalLiveReloadServer(LiveReloadServer server) { + this.server = server; + } + + /** + * {@link PostConstruct} method to start the server if possible. + * @throws Exception + */ + @PostConstruct + public void startServer() throws Exception { + if (this.server != null) { + try { + if (!this.server.isStarted()) { + this.server.start(); + } + logger.info("LiveReload server is running on port " + + this.server.getPort()); + } + catch (Exception ex) { + logger.warn("Unable to start LiveReload server"); + logger.debug("Live reload start error", ex); + this.server = null; + } + } + } + + /** + * Trigger LiveReload if the server is up an running. + */ + public void triggerReload() { + if (this.server != null) { + this.server.triggerReload(); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsAutoConfiguration.java new file mode 100644 index 00000000000..1a5239ddf81 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsAutoConfiguration.java @@ -0,0 +1,154 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +import java.util.Collection; + +import javax.servlet.Filter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.developertools.remote.server.AccessManager; +import org.springframework.boot.developertools.remote.server.Dispatcher; +import org.springframework.boot.developertools.remote.server.DispatcherFilter; +import org.springframework.boot.developertools.remote.server.Handler; +import org.springframework.boot.developertools.remote.server.HandlerMapper; +import org.springframework.boot.developertools.remote.server.HttpHeaderAccessManager; +import org.springframework.boot.developertools.remote.server.HttpStatusHandler; +import org.springframework.boot.developertools.remote.server.UrlHandlerMapper; +import org.springframework.boot.developertools.restart.server.DefaultSourceFolderUrlFilter; +import org.springframework.boot.developertools.restart.server.HttpRestartServer; +import org.springframework.boot.developertools.restart.server.HttpRestartServerHandler; +import org.springframework.boot.developertools.restart.server.SourceFolderUrlFilter; +import org.springframework.boot.developertools.tunnel.server.HttpTunnelServer; +import org.springframework.boot.developertools.tunnel.server.HttpTunnelServerHandler; +import org.springframework.boot.developertools.tunnel.server.RemoteDebugPortProvider; +import org.springframework.boot.developertools.tunnel.server.SocketTargetServerConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.ServerHttpRequest; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for remote development support. + * + * @author Phillip Webb + * @author Rob Winch + * @since 1.3.0 + */ +@Configuration +@ConditionalOnProperty(prefix = "spring.developertools.remote", name = "secret") +@ConditionalOnClass({ Filter.class, ServerHttpRequest.class }) +@EnableConfigurationProperties(DeveloperToolsProperties.class) +public class RemoteDeveloperToolsAutoConfiguration { + + private static final Log logger = LogFactory + .getLog(RemoteDeveloperToolsAutoConfiguration.class); + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + @ConditionalOnMissingBean + public AccessManager remoteDeveloperToolsAccessManager() { + RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote(); + return new HttpHeaderAccessManager(remoteProperties.getSecretHeaderName(), + remoteProperties.getSecret()); + } + + @Bean + public HandlerMapper remoteDeveloperToolsHealthCheckHandlerMapper() { + Handler handler = new HttpStatusHandler(); + return new UrlHandlerMapper(this.properties.getRemote().getContextPath(), handler); + } + + @Bean + @ConditionalOnMissingBean + public DispatcherFilter remoteDeveloperToolsDispatcherFilter( + AccessManager accessManager, Collection mappers) { + Dispatcher dispatcher = new Dispatcher(accessManager, mappers); + return new DispatcherFilter(dispatcher); + } + + /** + * Configuration for remote update and restarts. + */ + @ConditionalOnProperty(prefix = "spring.developertools.remote.restart", name = "enabled", matchIfMissing = true) + static class RemoteRestartConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + @ConditionalOnMissingBean + public SourceFolderUrlFilter remoteRestartSourceFolderUrlFilter() { + return new DefaultSourceFolderUrlFilter(); + } + + @Bean + @ConditionalOnMissingBean + public HttpRestartServer remoteRestartHttpRestartServer( + SourceFolderUrlFilter sourceFolderUrlFilter) { + return new HttpRestartServer(sourceFolderUrlFilter); + } + + @Bean + @ConditionalOnMissingBean(name = "remoteRestartHanderMapper") + public UrlHandlerMapper remoteRestartHanderMapper(HttpRestartServer server) { + String url = this.properties.getRemote().getContextPath() + "/restart"; + logger.warn("Listening for remote restart updates on " + url); + Handler handler = new HttpRestartServerHandler(server); + return new UrlHandlerMapper(url, handler); + } + + } + + /** + * Configuration for remote debug HTTP tunneling. + */ + @ConditionalOnProperty(prefix = "spring.developertools.remote.debug", name = "enabled", matchIfMissing = true) + static class RemoteDebugTunnelConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + @ConditionalOnMissingBean(name = "remoteDebugHanderMapper") + public UrlHandlerMapper remoteDebugHanderMapper( + @Qualifier("remoteDebugHttpTunnelServer") HttpTunnelServer server) { + String url = this.properties.getRemote().getContextPath() + "/debug"; + logger.warn("Listening for remote debug traffic on " + url); + Handler handler = new HttpTunnelServerHandler(server); + return new UrlHandlerMapper(url, handler); + } + + @Bean + @ConditionalOnMissingBean(name = "remoteDebugHttpTunnelServer") + public HttpTunnelServer remoteDebugHttpTunnelServer() { + return new HttpTunnelServer(new SocketTargetServerConnection( + new RemoteDebugPortProvider())); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsProperties.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsProperties.java new file mode 100644 index 00000000000..a975dcb1af9 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/RemoteDeveloperToolsProperties.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.autoconfigure; + +/** + * Configuration properties for remote Spring Boot applications. + * + * @author Phillip Webb + * @author Rob Winch + * @since 1.3.0 + * @see DeveloperToolsProperties + */ +public class RemoteDeveloperToolsProperties { + + public static final String DEFAULT_CONTEXT_PATH = "/.~~spring-boot!~"; + + public static final String DEFAULT_SECRET_HEADER_NAME = "X-AUTH-TOKEN"; + + /** + * Context path used to handle the remote connection. + */ + private String contextPath = DEFAULT_CONTEXT_PATH; + + /** + * A shared secret required to establish a connection (required to enable remote + * support). + */ + private String secret; + + /** + * HTTP header used to transfer the shared secret. + */ + private String secretHeaderName = DEFAULT_SECRET_HEADER_NAME; + + private Restart restart = new Restart(); + + private Debug debug = new Debug(); + + public String getContextPath() { + return this.contextPath; + } + + public void setContextPath(String contextPath) { + this.contextPath = contextPath; + } + + public String getSecret() { + return this.secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getSecretHeaderName() { + return this.secretHeaderName; + } + + public void setSecretHeaderName(String secretHeaderName) { + this.secretHeaderName = secretHeaderName; + } + + public Restart getRestart() { + return this.restart; + } + + public Debug getDebug() { + return this.debug; + } + + public static class Restart { + + /** + * Enable remote restart + */ + private boolean enabled = true; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + + public static class Debug { + + public static final Integer DEFAULT_LOCAL_PORT = 8000; + + /** + * Enable remote debug support. + */ + private boolean enabled = true; + + /** + * Local remote debug server port. + */ + private int localPort = DEFAULT_LOCAL_PORT; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getLocalPort() { + return this.localPort; + } + + public void setLocalPort(int localPort) { + this.localPort = localPort; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java new file mode 100644 index 00000000000..cb92f58568f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Auto-configuration for {@code spring-boot-developer-tools}. + */ +package org.springframework.boot.developertools.autoconfigure; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java new file mode 100644 index 00000000000..ddd70040920 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.classpath; + +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.context.ApplicationEvent; +import org.springframework.util.Assert; + +/** + * {@link ApplicationEvent} containing details of a classpath change. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileChangeListener + */ +public class ClassPathChangedEvent extends ApplicationEvent { + + private final Set changeSet; + + private final boolean restartRequired; + + /** + * Create a new {@link ClassPathChangedEvent}. + * @param source the source of the event + * @param changeSet the changed files + * @param restartRequired if a restart is required due to the change + */ + public ClassPathChangedEvent(Object source, Set changeSet, + boolean restartRequired) { + super(source); + Assert.notNull(changeSet, "ChangeSet must not be null"); + this.changeSet = changeSet; + this.restartRequired = restartRequired; + } + + /** + * Return details of the files that changed. + * @return the changed files + */ + public Set getChangeSet() { + return this.changeSet; + } + + /** + * Return if an application restart is required due to the change. + * @return if an application restart is required + */ + public boolean isRestartRequired() { + return this.restartRequired; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java new file mode 100644 index 00000000000..13a231c47c9 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.classpath; + +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.boot.developertools.filewatch.FileChangeListener; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.util.Assert; + +/** + * A {@link FileChangeListener} to publish {@link ClassPathChangedEvent + * ClassPathChangedEvents}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileSystemWatcher + */ +public class ClassPathFileChangeListener implements FileChangeListener { + + private final ApplicationEventPublisher eventPublisher; + + private final ClassPathRestartStrategy restartStrategy; + + /** + * Create a new {@link ClassPathFileChangeListener} instance. + * @param eventPublisher the event publisher used send events + * @param restartStrategy the restart strategy to use + */ + public ClassPathFileChangeListener(ApplicationEventPublisher eventPublisher, + ClassPathRestartStrategy restartStrategy) { + Assert.notNull(eventPublisher, "EventPublisher must not be null"); + Assert.notNull(restartStrategy, "RestartStrategy must not be null"); + this.eventPublisher = eventPublisher; + this.restartStrategy = restartStrategy; + } + + @Override + public void onChange(Set changeSet) { + boolean restart = isRestartRequired(changeSet); + ApplicationEvent event = new ClassPathChangedEvent(this, changeSet, restart); + this.eventPublisher.publishEvent(event); + } + + private boolean isRestartRequired(Set changeSet) { + for (ChangedFiles changedFiles : changeSet) { + for (ChangedFile changedFile : changedFiles) { + if (this.restartStrategy.isRestartRequired(changedFile)) { + return true; + } + } + } + return false; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java new file mode 100644 index 00000000000..826373fa700 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.classpath; + +import java.net.URL; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.developertools.filewatch.FileSystemWatcher; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; + +/** + * Encapsulates a {@link FileSystemWatcher} to watch the local classpath folders for + * changes. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileChangeListener + */ +public class ClassPathFileSystemWatcher implements InitializingBean, DisposableBean, + ApplicationContextAware { + + private static final Log logger = LogFactory.getLog(ClassPathFileSystemWatcher.class); + + private final FileSystemWatcher fileSystemWatcher; + + private ClassPathRestartStrategy restartStrategy; + + private ApplicationContext applicationContext; + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param urls the classpath URLs to watch + */ + public ClassPathFileSystemWatcher(URL[] urls) { + this(new FileSystemWatcher(), null, urls); + } + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param restartStrategy the classpath restart strategy + * @param urls the URLs to watch + */ + public ClassPathFileSystemWatcher(ClassPathRestartStrategy restartStrategy, URL[] urls) { + this(new FileSystemWatcher(), restartStrategy, urls); + } + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param fileSystemWatcher the underlying {@link FileSystemWatcher} used to monitor + * the local file system + * @param restartStrategy the classpath restart strategy + * @param urls the URLs to watch + */ + protected ClassPathFileSystemWatcher(FileSystemWatcher fileSystemWatcher, + ClassPathRestartStrategy restartStrategy, URL[] urls) { + Assert.notNull(fileSystemWatcher, "FileSystemWatcher must not be null"); + Assert.notNull(urls, "Urls must not be null"); + this.fileSystemWatcher = new FileSystemWatcher(); + this.restartStrategy = restartStrategy; + addUrls(urls); + } + + private void addUrls(URL[] urls) { + for (URL url : urls) { + addUrl(url); + } + } + + private void addUrl(URL url) { + if (url.getProtocol().equals("file") && url.getPath().endsWith("/")) { + try { + this.fileSystemWatcher.addSourceFolder(ResourceUtils.getFile(url)); + } + catch (Exception ex) { + logger.warn("Unable to watch classpath URL " + url); + logger.trace("Unable to watch classpath URL " + url, ex); + } + } + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (this.restartStrategy != null) { + this.fileSystemWatcher.addListener(new ClassPathFileChangeListener( + this.applicationContext, this.restartStrategy)); + } + this.fileSystemWatcher.start(); + } + + @Override + public void destroy() throws Exception { + this.fileSystemWatcher.stop(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java new file mode 100644 index 00000000000..0e5644f3e81 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.classpath; + +import org.springframework.boot.developertools.filewatch.ChangedFile; + +/** + * Strategy interface used to determine when a changed classpath file should trigger a + * full application restart. For example, static web resources might not require a full + * restart where as class files would. + * + * @author Phillip Webb + * @since 1.3.0 + * @see PatternClassPathRestartStrategy + */ +public interface ClassPathRestartStrategy { + + /** + * Return true if a full restart is required. + * @param file the changed file + * @return {@code true} if a full restart is required + */ + boolean isRestartRequired(ChangedFile file); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java new file mode 100644 index 00000000000..65af3b0f3f1 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.classpath; + +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; + +/** + * Ant style pattern based {@link ClassPathRestartStrategy}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathRestartStrategy + */ +public class PatternClassPathRestartStrategy implements ClassPathRestartStrategy { + + private final AntPathMatcher matcher = new AntPathMatcher(); + + private final String[] excludePatterns; + + public PatternClassPathRestartStrategy(String excludePatterns) { + this.excludePatterns = StringUtils + .commaDelimitedListToStringArray(excludePatterns); + } + + @Override + public boolean isRestartRequired(ChangedFile file) { + for (String pattern : this.excludePatterns) { + if (this.matcher.match(pattern, file.getRelativeName())) { + return false; + } + } + return true; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java new file mode 100644 index 00000000000..4ce5e42b61f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Support for classpath monitoring + */ +package org.springframework.boot.developertools.classpath; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java new file mode 100644 index 00000000000..7c7e71e5309 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A single file that has changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ChangedFiles + */ +public final class ChangedFile { + + private final File sourceFolder; + + private final File file; + + private final Type type; + + /** + * Create a new {@link ChangedFile} instance. + * @param sourceFolder the source folder + * @param file the file + * @param type the type of change + */ + public ChangedFile(File sourceFolder, File file, Type type) { + Assert.notNull(sourceFolder, "SourceFolder must not be null"); + Assert.notNull(file, "File must not be null"); + Assert.notNull(type, "Type must not be null"); + this.sourceFolder = sourceFolder; + this.file = file; + this.type = type; + } + + /** + * Return the file that was changed. + * @return the file + */ + public File getFile() { + return this.file; + } + + /** + * Return the type of change. + * @return the type of change + */ + public Type getType() { + return this.type; + } + + /** + * Return the name of the file relative to the source folder. + * @return the relative name + */ + public String getRelativeName() { + String folderName = this.sourceFolder.getAbsoluteFile().getPath(); + String fileName = this.file.getAbsoluteFile().getPath(); + Assert.state(fileName.startsWith(folderName), "The file " + fileName + + " is not contained in the source folder " + folderName); + return fileName.substring(folderName.length() + 1); + } + + @Override + public int hashCode() { + return this.file.hashCode() * 31 + this.type.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof ChangedFile) { + ChangedFile other = (ChangedFile) obj; + return this.file.equals(other.file) && this.type.equals(other.type); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.file + " (" + this.type + ")"; + } + + /** + * Change types. + */ + public static enum Type { + + /** + * A new file has been added. + */ + ADD, + + /** + * An existing file has been modified. + */ + MODIFY, + + /** + * An existing file has been deleted. + */ + DELETE + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java new file mode 100644 index 00000000000..87b2dfe442e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; + +/** + * A collections of files from a specific source folder that have changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see FileChangeListener + * @see ChangedFiles + */ +public final class ChangedFiles implements Iterable { + + private final File sourceFolder; + + private final Set files; + + public ChangedFiles(File sourceFolder, Set files) { + this.sourceFolder = sourceFolder; + this.files = Collections.unmodifiableSet(files); + } + + /** + * The source folder being watched. + * @return the source folder + */ + public File getSourceFolder() { + return this.sourceFolder; + } + + @Override + public Iterator iterator() { + return getFiles().iterator(); + } + + /** + * The files that have been changed. + * @return the changed files + */ + public Set getFiles() { + return this.files; + } + + @Override + public int hashCode() { + return this.files.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj instanceof ChangedFiles) { + ChangedFiles other = (ChangedFiles) obj; + return this.sourceFolder.equals(other.sourceFolder) + && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.sourceFolder + " " + this.files; + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java new file mode 100644 index 00000000000..11960289470 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.util.Set; + +/** + * Callback interface when file changes are detected. + * + * @author Andy Clement + * @author Phillip Webb + * @since 1.3.0 + */ +public interface FileChangeListener { + + /** + * Called when files have been changed. + * @param changeSet a set of the {@link ChangedFiles} + */ + void onChange(Set changeSet); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java new file mode 100644 index 00000000000..567616c3375 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A snapshot of a File at a given point in time. + * + * @author Phillip Webb + */ +class FileSnapshot { + + private final File file; + + private final boolean exists; + + private final long length; + + private final long lastModified; + + public FileSnapshot(File file) { + Assert.notNull(file, "File must not be null"); + Assert.isTrue(file.isFile() || !file.exists(), "File must not be a folder"); + this.file = file; + this.exists = file.exists(); + this.length = file.length(); + this.lastModified = file.lastModified(); + } + + public File getFile() { + return this.file; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FileSnapshot) { + FileSnapshot other = (FileSnapshot) obj; + boolean equals = this.file.equals(other.file); + equals &= this.exists == other.exists; + equals &= this.length == other.length; + equals &= this.lastModified == other.lastModified; + return equals; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.file.hashCode(); + hashCode = 31 * hashCode + (this.exists ? 1231 : 1237); + hashCode = 31 * hashCode + (int) (this.length ^ (this.length >>> 32)); + hashCode = 31 * hashCode + (int) (this.lastModified ^ (this.lastModified >>> 32)); + return hashCode; + } + + @Override + public String toString() { + return this.file.toString(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java new file mode 100644 index 00000000000..4ee5c31cad6 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.util.Assert; + +/** + * Watches specific folders for file changes. + * + * @author Andy Clement + * @author Phillip Webb + * @see FileChangeListener + * @since 1.3.0 + */ +public class FileSystemWatcher { + + private static final long DEFAULT_IDLE_TIME = 400; + + private static final long DEFAULT_QUIET_TIME = 200; + + private List listeners = new ArrayList(); + + private final boolean daemon; + + private final long idleTime; + + private final long quietTime; + + private Thread watchThread; + + private AtomicInteger remainingScans = new AtomicInteger(-1); + + private Map folders = new LinkedHashMap(); + + /** + * Create a new {@link FileSystemWatcher} instance. + */ + public FileSystemWatcher() { + this(true, DEFAULT_IDLE_TIME, DEFAULT_QUIET_TIME); + } + + /** + * Create a new {@link FileSystemWatcher} instance. + * @param daemon if a daemon thread used to monitor changes + * @param idleTime the amount of time to wait between checking for changes + * @param quietTime the amount of time required after a change has been detected to + * ensure that updates have completed + */ + public FileSystemWatcher(boolean daemon, long idleTime, long quietTime) { + this.daemon = daemon; + this.idleTime = idleTime; + this.quietTime = quietTime; + } + + /** + * Add listener for file change events. Cannot be called after the watcher has been + * {@link #start() started}. + * @param fileChangeListener the listener to add + */ + public synchronized void addListener(FileChangeListener fileChangeListener) { + Assert.notNull(fileChangeListener, "FileChangeListener must not be null"); + checkNotStarted(); + this.listeners.add(fileChangeListener); + } + + /** + * Add a source folder to monitor. Cannot be called after the watcher has been + * {@link #start() started}. + * @param folder the folder to monitor + */ + public synchronized void addSourceFolder(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + checkNotStarted(); + this.folders.put(folder, null); + } + + private void checkNotStarted() { + Assert.state(this.watchThread == null, "FileSystemWatcher already started"); + } + + /** + * Start monitoring the source folder for changes. + */ + public synchronized void start() { + saveInitalSnapshots(); + if (this.watchThread == null) { + this.watchThread = new Thread() { + @Override + public void run() { + int remainingScans = FileSystemWatcher.this.remainingScans.get(); + while (remainingScans > 0 || remainingScans == -1) { + try { + if (remainingScans > 0) { + FileSystemWatcher.this.remainingScans.decrementAndGet(); + } + scan(); + remainingScans = FileSystemWatcher.this.remainingScans.get(); + } + catch (InterruptedException ex) { + } + } + }; + }; + this.watchThread.setName("File Watcher"); + this.watchThread.setDaemon(this.daemon); + this.remainingScans = new AtomicInteger(-1); + this.watchThread.start(); + } + } + + private void saveInitalSnapshots() { + for (File folder : this.folders.keySet()) { + this.folders.put(folder, new FolderSnapshot(folder)); + } + } + + private void scan() throws InterruptedException { + Thread.sleep(this.idleTime - this.quietTime); + Set previous; + Set current = new HashSet(this.folders.values()); + do { + previous = current; + current = getCurrentSnapshots(); + Thread.sleep(this.quietTime); + } + while (!previous.equals(current)); + updateSnapshots(current); + } + + private Set getCurrentSnapshots() { + Set snapshots = new LinkedHashSet(); + for (File folder : this.folders.keySet()) { + snapshots.add(new FolderSnapshot(folder)); + } + return snapshots; + } + + private void updateSnapshots(Set snapshots) { + Map updated = new LinkedHashMap(); + Set changeSet = new LinkedHashSet(); + for (FolderSnapshot snapshot : snapshots) { + FolderSnapshot previous = this.folders.get(snapshot.getFolder()); + updated.put(snapshot.getFolder(), snapshot); + ChangedFiles changedFiles = previous.getChangedFiles(snapshot); + if (!changedFiles.getFiles().isEmpty()) { + changeSet.add(changedFiles); + } + } + if (!changeSet.isEmpty()) { + fireListeners(Collections.unmodifiableSet(changeSet)); + } + this.folders = updated; + } + + private void fireListeners(Set changeSet) { + for (FileChangeListener listener : this.listeners) { + listener.onChange(changeSet); + } + } + + /** + * Stop monitoring the source folders. + */ + public synchronized void stop() { + stopAfter(0); + } + + /** + * Stop monitoring the source folders. + * @param remainingScans the number of scans remaming + */ + synchronized void stopAfter(int remainingScans) { + Thread thread = this.watchThread; + if (thread != null) { + this.remainingScans.set(remainingScans); + try { + thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.watchThread = null; + } + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java new file mode 100644 index 00000000000..87534b919a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.Assert; + +/** + * A snapshot of a folder at a given point in time. + * + * @author Phillip Webb + */ +class FolderSnapshot { + + private static final Set DOT_FOLDERS = Collections + .unmodifiableSet(new HashSet(Arrays.asList(".", ".."))); + + private final File folder; + + private final Date time; + + private Set files; + + /** + * Create a new {@link FolderSnapshot} for the given folder. + * @param folder the source folder + */ + public FolderSnapshot(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + this.folder = folder; + this.time = new Date(); + Set files = new LinkedHashSet(); + collectFiles(folder, files); + this.files = Collections.unmodifiableSet(files); + } + + private void collectFiles(File source, Set result) { + File[] children = source.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory() && !DOT_FOLDERS.contains(child.getName())) { + collectFiles(child, result); + } + else if (child.isFile()) { + result.add(new FileSnapshot(child)); + } + } + } + } + + public ChangedFiles getChangedFiles(FolderSnapshot snapshot) { + Assert.notNull(snapshot, "Snapshot must not be null"); + File folder = this.folder; + Assert.isTrue(snapshot.folder.equals(folder), "Snapshot source folder must be '" + + folder + "'"); + Set changes = new LinkedHashSet(); + Map previousFiles = getFilesMap(); + for (FileSnapshot currentFile : snapshot.files) { + FileSnapshot previousFile = previousFiles.remove(currentFile.getFile()); + if (previousFile == null) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.ADD)); + } + else if (!previousFile.equals(currentFile)) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.MODIFY)); + } + } + for (FileSnapshot previousFile : previousFiles.values()) { + changes.add(new ChangedFile(folder, previousFile.getFile(), Type.DELETE)); + } + return new ChangedFiles(folder, changes); + } + + private Map getFilesMap() { + Map files = new LinkedHashMap(); + for (FileSnapshot file : this.files) { + files.put(file.getFile(), file); + } + return files; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FolderSnapshot) { + FolderSnapshot other = (FolderSnapshot) obj; + return this.folder.equals(other.folder) && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.folder.hashCode(); + hashCode = 31 * hashCode + this.files.hashCode(); + return hashCode; + } + + /** + * Return the source folder of this snapshot. + * @return the source folder + */ + public File getFolder() { + return this.folder; + } + + @Override + public String toString() { + return this.folder + " snaphost at " + this.time; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java new file mode 100644 index 00000000000..1bc251d01a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Class to watch the local filesystem for changes. + */ +package org.springframework.boot.developertools.filewatch; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java new file mode 100644 index 00000000000..4453d01352b --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.nio.charset.Charset; + +/** + * Simple Base64 Encoder. + * + * @author Phillip Webb + */ +class Base64Encoder { + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static final String ALPHABET_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789+/"; + + static final byte[] ALPHABET = ALPHABET_CHARS.getBytes(UTF_8); + + private static final byte EQUALS_SIGN = '='; + + public static String encode(String string) { + return encode(string.getBytes(UTF_8)); + } + + public static String encode(byte[] bytes) { + byte[] encoded = new byte[bytes.length / 3 * 4 + (bytes.length % 3 == 0 ? 0 : 4)]; + for (int i = 0; i < encoded.length; i += 3) { + encodeBlock(bytes, i, Math.min((bytes.length - i), 3), encoded, i / 3 * 4); + } + return new String(encoded, UTF_8); + } + + private static void encodeBlock(byte[] src, int srcPos, int blockLen, byte[] dest, + int destPos) { + if (blockLen > 0) { + int inBuff = (blockLen > 0 ? ((src[srcPos] << 24) >>> 8) : 0) + | (blockLen > 1 ? ((src[srcPos + 1] << 24) >>> 16) : 0) + | (blockLen > 2 ? ((src[srcPos + 2] << 24) >>> 24) : 0); + for (int i = 0; i < 4; i++) { + dest[destPos + i] = (i > blockLen ? EQUALS_SIGN + : ALPHABET[(inBuff >>> (6 * (3 - i))) & 0x3f]); + } + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java new file mode 100644 index 00000000000..04c36df8bdf --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * A {@link LiveReloadServer} connection. + */ +class Connection { + + private static Log logger = LogFactory.getLog(Connection.class); + + private static final Pattern WEBSOCKET_KEY_PATTERN = Pattern.compile( + "^Sec-WebSocket-Key:(.*)$", Pattern.MULTILINE); + + public final static String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + private final Socket socket; + + private final ConnectionInputStream inputStream; + + private final ConnectionOutputStream outputStream; + + private final String header; + + private volatile boolean webSocket; + + private volatile boolean running = true; + + /** + * Create a new {@link Connection} instance. + * @param socket the source socket + * @param inputStream the socket input stream + * @param outputStream the socket output stream + * @throws IOException + */ + public Connection(Socket socket, InputStream inputStream, OutputStream outputStream) + throws IOException { + this.socket = socket; + this.inputStream = new ConnectionInputStream(inputStream); + this.outputStream = new ConnectionOutputStream(outputStream); + this.header = this.inputStream.readHeader(); + logger.debug("Established livereload connection [" + this.header + "]"); + } + + /** + * Run the connection. + * @throws Exception + */ + public void run() throws Exception { + if (this.header.contains("Upgrade: websocket") + && this.header.contains("Sec-WebSocket-Version: 13")) { + runWebSocket(this.header); + } + if (this.header.contains("GET /livereload.js")) { + this.outputStream.writeHttp(getClass().getResourceAsStream("livereload.js"), + "text/javascript"); + } + } + + private void runWebSocket(String header) throws Exception { + String accept = getWebsocketAcceptResponse(); + this.outputStream.writeHeaders("HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Accept: " + + accept); + new Frame("{\"command\":\"hello\",\"protocols\":" + + "[\"http://livereload.com/protocols/official-7\"]," + + "\"serverName\":\"spring-boot\"}").write(this.outputStream); + Thread.sleep(100); + this.webSocket = true; + while (this.running) { + readWebSocketFrame(); + } + } + + private void readWebSocketFrame() throws IOException { + try { + Frame frame = Frame.read(this.inputStream); + if (frame.getType() == Frame.Type.PING) { + writeWebSocketFrame(new Frame(Frame.Type.PONG)); + } + else if (frame.getType() == Frame.Type.CLOSE) { + throw new ConnectionClosedException(); + } + else if (frame.getType() == Frame.Type.TEXT) { + logger.debug("Recieved LiveReload text frame " + frame); + } + else { + throw new IOException("Unexpected Frame Type " + frame.getType()); + } + } + catch (SocketTimeoutException ex) { + writeWebSocketFrame(new Frame(Frame.Type.PING)); + Frame frame = Frame.read(this.inputStream); + if (frame.getType() != Frame.Type.PONG) { + throw new IllegalStateException("No Pong"); + } + } + } + + /** + * Trigger livereload for the client using this connection. + * @throws IOException + */ + public void triggerReload() throws IOException { + if (this.webSocket) { + logger.debug("Triggering LiveReload"); + writeWebSocketFrame(new Frame("{\"command\":\"reload\",\"path\":\"/\"}")); + } + } + + private synchronized void writeWebSocketFrame(Frame frame) throws IOException { + frame.write(this.outputStream); + } + + private String getWebsocketAcceptResponse() throws NoSuchAlgorithmException { + Matcher matcher = WEBSOCKET_KEY_PATTERN.matcher(this.header); + if (!matcher.find()) { + throw new IllegalStateException("No Sec-WebSocket-Key"); + } + String response = matcher.group(1).trim() + WEBSOCKET_GUID; + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + messageDigest.update(response.getBytes(), 0, response.length()); + return Base64Encoder.encode(messageDigest.digest()); + } + + /** + * Close the connection. + * @throws IOException + */ + public void close() throws IOException { + this.running = false; + this.socket.close(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java new file mode 100644 index 00000000000..0916c3a4faf --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.IOException; + +/** + * Exception throw when the client closes the connection. + * + * @author Phillip Webb + */ +class ConnectionClosedException extends IOException { + + public ConnectionClosedException() { + super("Connection closed"); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java new file mode 100644 index 00000000000..a86c338fa4d --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * {@link InputStream} for a server connection. + * + * @author Phillip Webb + */ +class ConnectionInputStream extends FilterInputStream { + + private static final String HEADER_END = "\r\n\r\n"; + + private static final int BUFFER_SIZE = 4096; + + public ConnectionInputStream(InputStream in) { + super(in); + } + + /** + * Read the HTTP header from the {@link InputStream}. Note: This method doesn't expect + * any HTTP content after the header since the initial request is usually just a + * WebSocket upgrade. + * @return the HTTP header + * @throws IOException + */ + public String readHeader() throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + StringBuffer content = new StringBuffer(BUFFER_SIZE); + while (content.indexOf(HEADER_END) == -1) { + int amountRead = checkedRead(buffer, 0, BUFFER_SIZE); + content.append(new String(buffer, 0, amountRead)); + } + return content.substring(0, content.indexOf(HEADER_END)).toString(); + } + + /** + * Repeatedly read the underlying {@link InputStream} until the requested number of + * bytes have been loaded. + * @param buffer the destination buffer + * @param offset the buffer offset + * @param length the amount of data to read + * @throws IOException + */ + public void readFully(byte[] buffer, int offset, int length) throws IOException { + while (length > 0) { + int amountRead = checkedRead(buffer, offset, length); + offset += amountRead; + length -= amountRead; + } + } + + /** + * Read a single byte from the stream (checking that the end of the stream hasn't been + * reached. + * @return the content + * @throws IOException + */ + public int checkedRead() throws IOException { + int b = read(); + if (b == -1) { + throw new IOException("End of stream"); + } + return (b & 0xff); + } + + /** + * Read a a number of bytes from the stream (checking that the end of the stream + * hasn't been reached) + * @param buffer the destination buffer + * @param offset the buffer offset + * @param length the length to read + * @return the amount of data read + * @throws IOException + */ + public int checkedRead(byte[] buffer, int offset, int length) throws IOException { + int amountRead = read(buffer, offset, length); + if (amountRead == -1) { + throw new IOException("End of stream"); + } + return amountRead; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java new file mode 100644 index 00000000000..4a3f14fc82a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.springframework.util.FileCopyUtils; + +/** + * {@link OutputStream} for a server connection. + * + * @author Phillip Webb + */ +class ConnectionOutputStream extends FilterOutputStream { + + public ConnectionOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.out.write(b, off, len); + } + + public void writeHttp(InputStream content, String contentType) throws IOException { + byte[] bytes = FileCopyUtils.copyToByteArray(content); + writeHeaders("HTTP/1.1 200 OK", "Content-Type: " + contentType, + "Content-Length: " + bytes.length, "Connection: close"); + write(bytes); + flush(); + } + + public void writeHeaders(String... headers) throws IOException { + StringBuilder response = new StringBuilder(); + for (String header : headers) { + response.append(header).append("\r\n"); + } + response.append("\r\n"); + write(response.toString().getBytes()); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java new file mode 100644 index 00000000000..138957d8dde --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.OutputStream; + +import org.springframework.util.Assert; + +/** + * A limited implementation of a WebSocket Frame used to carry LiveReload data. + * + * @author Phillip Webb + */ +class Frame { + + private static final byte[] NO_BYTES = new byte[0]; + + private final Type type; + + private final byte[] payload; + + /** + * Create a new {@link Type#TEXT text} {@link Frame} instance with the specified + * payload. + * @param payload the text payload + */ + public Frame(String payload) { + Assert.notNull(payload, "Payload must not be null"); + this.type = Type.TEXT; + this.payload = payload.getBytes(); + } + + public Frame(Type type) { + Assert.notNull(type, "Type must not be null"); + this.type = type; + this.payload = NO_BYTES; + } + + private Frame(Type type, byte[] payload) { + this.type = type; + this.payload = payload; + } + + public Type getType() { + return this.type; + } + + public byte[] getPayload() { + return this.payload; + } + + @Override + public String toString() { + return new String(this.payload); + } + + public void write(OutputStream outputStream) throws IOException { + outputStream.write(0x80 | this.type.code); + if (this.payload.length < 126) { + outputStream.write(0x00 | (this.payload.length & 0x7F)); + } + else { + outputStream.write(0x7E); + outputStream.write(this.payload.length >> 8 & 0xFF); + outputStream.write(this.payload.length >> 0 & 0xFF); + } + outputStream.write(this.payload); + outputStream.flush(); + } + + public static Frame read(ConnectionInputStream inputStream) throws IOException { + int firstByte = inputStream.checkedRead(); + Assert.state((firstByte & 0x80) != 0, "Fragmented frames are not supported"); + int maskAndLength = inputStream.checkedRead(); + boolean hasMask = (maskAndLength & 0x80) != 0; + int length = (maskAndLength & 0x7F); + Assert.state(length != 127, "Large frames are not supported"); + if (length == 126) { + length = ((inputStream.checkedRead()) << 8 | inputStream.checkedRead()); + } + byte[] mask = new byte[4]; + if (hasMask) { + inputStream.readFully(mask, 0, mask.length); + } + byte[] payload = new byte[length]; + inputStream.readFully(payload, 0, length); + if (hasMask) { + for (int i = 0; i < payload.length; i++) { + payload[i] ^= mask[i % 4]; + } + } + return new Frame(Type.forCode(firstByte & 0x0F), payload); + } + + public static enum Type { + + /** + * Continuation frame. + */ + CONTINUATION(0x00), + + /** + * Text frame. + */ + TEXT(0x01), + + /** + * Binary frame. + */ + BINARY(0x02), + + /** + * Close frame. + */ + CLOSE(0x08), + + /** + * Ping frame. + */ + PING(0x09), + + /** + * Pong frame. + */ + PONG(0x0A); + + private final int code; + + private Type(int code) { + this.code = code; + } + + public static Type forCode(int code) { + for (Type type : values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalStateException("Unknown code " + code); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java new file mode 100644 index 00000000000..49420b607b3 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java @@ -0,0 +1,322 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; + +/** + * A livereload server. + * + * @author Phillip Webb + * @see livereload.com + * @since 1.3.0 + */ +public class LiveReloadServer { + + /** + * The default live reload server port. + */ + public static final int DEFAULT_PORT = 35729; + + private static Log logger = LogFactory.getLog(LiveReloadServer.class); + + private static final int READ_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(4); + + private final int port; + + private final ThreadFactory threadFactory; + + private ServerSocket serverSocket; + + private Thread listenThread; + + private ExecutorService executor = Executors + .newCachedThreadPool(new WorkerThreadFactory()); + + private List connections = new ArrayList(); + + /** + * Create a new {@link LiveReloadServer} listening on the default port. + */ + public LiveReloadServer() { + this(DEFAULT_PORT); + } + + /** + * Create a new {@link LiveReloadServer} listening on the default port with a specific + * {@link ThreadFactory}. + * @param threadFactory the thread factory + */ + public LiveReloadServer(ThreadFactory threadFactory) { + this(DEFAULT_PORT, threadFactory); + } + + /** + * Create a new {@link LiveReloadServer} listening on the specified port. + * @param port the listen port + */ + public LiveReloadServer(int port) { + this(port, new ThreadFactory() { + + @Override + public Thread newThread(Runnable runnable) { + return new Thread(runnable); + } + + }); + } + + /** + * Create a new {@link LiveReloadServer} listening on the specified port with a + * specific {@link ThreadFactory}. + * @param port the listen port + * @param threadFactory the thread factory + */ + public LiveReloadServer(int port, ThreadFactory threadFactory) { + this.port = port; + this.threadFactory = threadFactory; + } + + /** + * Start the livereload server and accept incoming connections. + * @throws IOException + */ + public synchronized void start() throws IOException { + Assert.state(!isStarted(), "Server already started"); + logger.debug("Starting live reload server on port " + this.port); + this.serverSocket = new ServerSocket(this.port); + this.listenThread = this.threadFactory.newThread(new Runnable() { + + @Override + public void run() { + acceptConnections(); + } + + }); + this.listenThread.setDaemon(true); + this.listenThread.setName("Live Reload Server"); + this.listenThread.start(); + } + + /** + * Return if the server has been started. + * @return {@code true} if the server is running + */ + public synchronized boolean isStarted() { + return this.listenThread != null; + } + + /** + * Return the port that the server is listening on + * @return the server port + */ + public int getPort() { + return this.port; + } + + private void acceptConnections() { + do { + try { + Socket socket = this.serverSocket.accept(); + socket.setSoTimeout(READ_TIMEOUT); + this.executor.execute(new ConnectionHandler(socket)); + } + catch (SocketTimeoutException ex) { + // Ignore + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("LiveReload server error", ex); + } + } + } + while (!this.serverSocket.isClosed()); + } + + /** + * Gracefully stop the livereload server. + * @throws IOException + */ + public synchronized void stop() throws IOException { + if (this.listenThread != null) { + closeAllConnections(); + try { + this.executor.shutdown(); + this.executor.awaitTermination(1, TimeUnit.MINUTES); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.serverSocket.close(); + try { + this.listenThread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.listenThread = null; + this.serverSocket = null; + } + } + + private void closeAllConnections() throws IOException { + synchronized (this.connections) { + for (Connection connection : this.connections) { + connection.close(); + } + } + } + + /** + * Trigger livereload of all connected clients. + */ + public void triggerReload() { + synchronized (this.connections) { + for (Connection connection : this.connections) { + try { + connection.triggerReload(); + } + catch (Exception ex) { + logger.debug("Unable to send reload message", ex); + } + } + } + } + + private void addConnection(Connection connection) { + synchronized (this.connections) { + this.connections.add(connection); + } + } + + private void removeConnection(Connection connection) { + synchronized (this.connections) { + this.connections.remove(connection); + } + } + + /** + * Factory method used to create the {@link Connection}. + * @param socket the source socket + * @param inputStream the socket input stream + * @param outputStream the socket output stream + * @return a connection + * @throws IOException + */ + protected Connection createConnection(Socket socket, InputStream inputStream, + OutputStream outputStream) throws IOException { + return new Connection(socket, inputStream, outputStream); + } + + /** + * {@link Runnable} to handle a single connection. + * @see Connection + */ + private class ConnectionHandler implements Runnable { + + private final Socket socket; + + private final InputStream inputStream; + + public ConnectionHandler(Socket socket) throws IOException { + this.socket = socket; + this.inputStream = socket.getInputStream(); + } + + @Override + public void run() { + try { + handle(); + } + catch (ConnectionClosedException ex) { + logger.debug("LiveReload connection closed"); + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("LiveReload error", ex); + } + } + } + + private void handle() throws Exception { + try { + try { + OutputStream outputStream = this.socket.getOutputStream(); + try { + Connection connection = createConnection(this.socket, + this.inputStream, outputStream); + runConnection(connection); + } + finally { + outputStream.close(); + } + } + finally { + this.inputStream.close(); + } + } + finally { + this.socket.close(); + } + } + + private void runConnection(Connection connection) throws IOException, Exception { + try { + addConnection(connection); + connection.run(); + } + finally { + removeConnection(connection); + } + } + + } + + /** + * {@link ThreadFactory} to create the worker threads, + */ + private static class WorkerThreadFactory implements ThreadFactory { + + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r); + thread.setDaemon(true); + thread.setName("Live Reload #" + this.threadNumber.getAndIncrement()); + return thread; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java new file mode 100644 index 00000000000..64c1937a3fb --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Support for the livereload protocol. + */ +package org.springframework.boot.developertools.livereload; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/package-info.java new file mode 100644 index 00000000000..9c0f4c95ff4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Spring Boot developer tools. + */ +package org.springframework.boot.developertools; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/ClassPathChangeUploader.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/ClassPathChangeUploader.java new file mode 100644 index 00000000000..4a7485c0d00 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/ClassPathChangeUploader.java @@ -0,0 +1,145 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.context.ApplicationListener; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; + +/** + * Listens and pushes any classpath updates to a remote endpoint. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class ClassPathChangeUploader implements + ApplicationListener { + + private static final Map TYPE_MAPPINGS; + static { + Map map = new HashMap(); + map.put(ChangedFile.Type.ADD, ClassLoaderFile.Kind.ADDED); + map.put(ChangedFile.Type.DELETE, ClassLoaderFile.Kind.DELETED); + map.put(ChangedFile.Type.MODIFY, ClassLoaderFile.Kind.MODIFIED); + TYPE_MAPPINGS = Collections.unmodifiableMap(map); + } + + private static final Log logger = LogFactory.getLog(ClassPathChangeUploader.class); + + private final URI uri; + + private final ClientHttpRequestFactory requestFactory; + + public ClassPathChangeUploader(String url, ClientHttpRequestFactory requestFactory) { + Assert.hasLength(url, "URL must not be empty"); + Assert.notNull(requestFactory, "RequestFactory must not be null"); + try { + this.uri = new URL(url).toURI(); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException("Malformed URL '" + url + "'"); + } + catch (MalformedURLException ex) { + throw new IllegalArgumentException("Malformed URL '" + url + "'"); + } + this.requestFactory = requestFactory; + } + + @Override + public void onApplicationEvent(ClassPathChangedEvent event) { + try { + ClassLoaderFiles classLoaderFiles = getClassLoaderFiles(event); + ClientHttpRequest request = this.requestFactory.createRequest(this.uri, + HttpMethod.POST); + byte[] bytes = serialize(classLoaderFiles); + HttpHeaders headers = request.getHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentLength(bytes.length); + FileCopyUtils.copy(bytes, request.getBody()); + logUpload(classLoaderFiles); + ClientHttpResponse response = request.execute(); + Assert.state(response.getStatusCode() == HttpStatus.OK, "Unexpected " + + response.getStatusCode() + " response uploading class files"); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private void logUpload(ClassLoaderFiles classLoaderFiles) { + int size = classLoaderFiles.size(); + logger.info("Uploaded " + size + " class " + + (size == 1 ? "resource" : "resources")); + } + + private byte[] serialize(ClassLoaderFiles classLoaderFiles) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream); + objectOutputStream.writeObject(classLoaderFiles); + objectOutputStream.close(); + return outputStream.toByteArray(); + } + + private ClassLoaderFiles getClassLoaderFiles(ClassPathChangedEvent event) + throws IOException { + ClassLoaderFiles files = new ClassLoaderFiles(); + for (ChangedFiles changedFiles : event.getChangeSet()) { + String sourceFolder = changedFiles.getSourceFolder().getAbsolutePath(); + for (ChangedFile changedFile : changedFiles) { + files.addFile(sourceFolder, changedFile.getRelativeName(), + asClassLoaderFile(changedFile)); + } + } + return files; + } + + private ClassLoaderFile asClassLoaderFile(ChangedFile changedFile) throws IOException { + ClassLoaderFile.Kind kind = TYPE_MAPPINGS.get(changedFile.getType()); + byte[] bytes = (kind == Kind.DELETED ? null : FileCopyUtils + .copyToByteArray(changedFile.getFile())); + long lastModified = (kind == Kind.DELETED ? System.currentTimeMillis() + : changedFile.getFile().lastModified()); + return new ClassLoaderFile(kind, lastModified, bytes); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java new file mode 100644 index 00000000000..594140dc404 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.util.Assert; + +/** + * {@link Runnable} that waits to triggers live reload until the remote server has + * restarted. + * + * @author Phillip Webb + */ +class DelayedLiveReloadTrigger implements Runnable { + + private static final long SHUTDOWN_TIME = 1000; + + private static final long SLEEP_TIME = 500; + + private static final long TIMEOUT = 30000; + + private static final Log logger = LogFactory.getLog(DelayedLiveReloadTrigger.class); + + private final OptionalLiveReloadServer liveReloadServer; + + private final ClientHttpRequestFactory requestFactory; + + private final URI uri; + + private long shutdownTime = SHUTDOWN_TIME; + + private long sleepTime = SLEEP_TIME; + + private long timeout = TIMEOUT; + + public DelayedLiveReloadTrigger(OptionalLiveReloadServer liveReloadServer, + ClientHttpRequestFactory requestFactory, String url) { + Assert.notNull(liveReloadServer, "LiveReloadServer must not be null"); + Assert.notNull(requestFactory, "RequestFactory must not be null"); + Assert.hasLength(url, "URL must not be empty"); + this.liveReloadServer = liveReloadServer; + this.requestFactory = requestFactory; + try { + this.uri = new URI(url); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + protected void setTimings(long shutdown, long sleep, long timeout) { + this.shutdownTime = shutdown; + this.sleepTime = sleep; + this.timeout = timeout; + } + + @Override + public void run() { + try { + Thread.sleep(this.shutdownTime); + long start = System.currentTimeMillis(); + while (!isUp()) { + long runTime = System.currentTimeMillis() - start; + if (runTime > this.timeout) { + return; + } + Thread.sleep(this.sleepTime); + } + logger.info("Remote server has changed, triggering LiveReload"); + this.liveReloadServer.triggerReload(); + } + catch (InterruptedException ex) { + } + } + + private boolean isUp() { + try { + ClientHttpRequest request = createRequest(); + ClientHttpResponse response = request.execute(); + return response.getStatusCode() == HttpStatus.OK; + } + catch (Exception ex) { + return false; + } + } + + private ClientHttpRequest createRequest() throws IOException { + return this.requestFactory.createRequest(this.uri, HttpMethod.GET); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/HttpHeaderInterceptor.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/HttpHeaderInterceptor.java new file mode 100644 index 00000000000..b6effd43bc2 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/HttpHeaderInterceptor.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import java.io.IOException; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.util.Assert; + +/** + * {@link ClientHttpRequestInterceptor} to populate arbitrary HTTP headers with a value. + * For example, it might be used to provide an X-AUTH-TOKEN and value for security + * purposes. + * + * @author Rob Winch + * @since 1.3.0 + */ +public class HttpHeaderInterceptor implements ClientHttpRequestInterceptor { + + private final String name; + + private final String value; + + /** + * Creates a new {@link HttpHeaderInterceptor} instance. + * @param name the header name to populate. Cannot be null or empty. + * @param value the header value to populate. Cannot be null or empty. + */ + public HttpHeaderInterceptor(String name, String value) { + Assert.hasLength(name, "Name must not be empty"); + Assert.hasLength(value, "Value" + " must not be empty"); + this.name = name; + this.value = value; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + request.getHeaders().add(this.name, this.value); + return execution.execute(request, body); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LocalDebugPortAvailableCondition.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LocalDebugPortAvailableCondition.java new file mode 100644 index 00000000000..c57b681827b --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LocalDebugPortAvailableCondition.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import javax.net.ServerSocketFactory; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.boot.bind.RelaxedPropertyResolver; +import org.springframework.boot.developertools.autoconfigure.RemoteDeveloperToolsProperties; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Condition used to check that the actual local port is available. + */ +class LocalDebugPortAvailableCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, + AnnotatedTypeMetadata metadata) { + RelaxedPropertyResolver resolver = new RelaxedPropertyResolver( + context.getEnvironment(), "spring.developertools.remote.debug."); + Integer port = resolver.getProperty("local-port", Integer.class); + if (port == null) { + port = RemoteDeveloperToolsProperties.Debug.DEFAULT_LOCAL_PORT; + } + if (isPortAvailable(port)) { + return ConditionOutcome.match("Local debug port availble"); + } + return ConditionOutcome.noMatch("Local debug port unavailble"); + } + + private boolean isPortAvailable(int port) { + try { + ServerSocketFactory.getDefault().createServerSocket(port).close(); + return true; + } + catch (Exception ex) { + return false; + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LoggingTunnelClientListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LoggingTunnelClientListener.java new file mode 100644 index 00000000000..c5500ce5d3c --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/LoggingTunnelClientListener.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import java.nio.channels.SocketChannel; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.tunnel.client.TunnelClientListener; + +/** + * {@link TunnelClientListener} to log open/close events. + * + * @author Phillip Webb + */ +class LoggingTunnelClientListener implements TunnelClientListener { + + private static final Log logger = LogFactory + .getLog(LoggingTunnelClientListener.class); + + @Override + public void onOpen(SocketChannel socket) { + logger.info("Remote debug connection opened"); + } + + @Override + public void onClose(SocketChannel socket) { + logger.info("Remote debug connection closed"); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java new file mode 100644 index 00000000000..432686dfebd --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java @@ -0,0 +1,228 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.client; + +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.annotation.PostConstruct; +import javax.servlet.Filter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.developertools.autoconfigure.DeveloperToolsProperties; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; +import org.springframework.boot.developertools.autoconfigure.RemoteDeveloperToolsProperties; +import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; +import org.springframework.boot.developertools.classpath.ClassPathFileSystemWatcher; +import org.springframework.boot.developertools.classpath.ClassPathRestartStrategy; +import org.springframework.boot.developertools.classpath.PatternClassPathRestartStrategy; +import org.springframework.boot.developertools.livereload.LiveReloadServer; +import org.springframework.boot.developertools.restart.DefaultRestartInitializer; +import org.springframework.boot.developertools.restart.RestartScope; +import org.springframework.boot.developertools.restart.Restarter; +import org.springframework.boot.developertools.tunnel.client.HttpTunnelConnection; +import org.springframework.boot.developertools.tunnel.client.TunnelClient; +import org.springframework.boot.developertools.tunnel.client.TunnelConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.InterceptingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.util.Assert; + +/** + * Configuration used to connect to remote Spring Boot applications. + * + * @author Phillip Webb + * @since 1.3.0 + * @see org.springframework.boot.developertools.RemoteSpringApplication + */ +@Configuration +@EnableConfigurationProperties(DeveloperToolsProperties.class) +public class RemoteClientConfiguration { + + private static final Log logger = LogFactory.getLog(RemoteClientConfiguration.class); + + @Autowired + private DeveloperToolsProperties properties; + + @Value("${remoteUrl}") + private String remoteUrl; + + @Bean + public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + @Bean + public ClientHttpRequestFactory clientHttpRequestFactory() { + List interceptors = Arrays + .asList(getSecurityInterceptor()); + return new InterceptingClientHttpRequestFactory( + new SimpleClientHttpRequestFactory(), interceptors); + } + + private ClientHttpRequestInterceptor getSecurityInterceptor() { + RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote(); + String secretHeaderName = remoteProperties.getSecretHeaderName(); + String secret = remoteProperties.getSecret(); + Assert.state(secret != null, + "The environment value 'spring.developertools.remote.secret' " + + "is required to secure your connection."); + return new HttpHeaderInterceptor(secretHeaderName, secret); + } + + @PostConstruct + private void logWarnings() { + RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote(); + if (!remoteProperties.getDebug().isEnabled() + && !remoteProperties.getRestart().isEnabled()) { + logger.warn("Remote restart and debug are both disabled."); + } + if (!this.remoteUrl.startsWith("https://")) { + logger.warn("The connection to " + this.remoteUrl + + " is insecure. You should use a URL starting with 'https://'."); + } + } + + /** + * LiveReload configuration. + */ + @ConditionalOnProperty(prefix = "spring.developertools.livereload", name = "enabled", matchIfMissing = true) + static class LiveReloadConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Autowired(required = false) + private LiveReloadServer liveReloadServer; + + @Autowired + private ClientHttpRequestFactory clientHttpRequestFactory; + + @Value("${remoteUrl}") + private String remoteUrl; + + private ExecutorService executor = Executors.newSingleThreadExecutor(); + + @Bean + @RestartScope + @ConditionalOnMissingBean + public LiveReloadServer liveReloadServer() { + return new LiveReloadServer(this.properties.getLivereload().getPort(), + Restarter.getInstance().getThreadFactory()); + } + + @EventListener + public void onClassPathChanged(ClassPathChangedEvent event) { + String url = this.remoteUrl + this.properties.getRemote().getContextPath(); + this.executor.execute(new DelayedLiveReloadTrigger( + optionalLiveReloadServer(), this.clientHttpRequestFactory, url)); + } + + @Bean + public OptionalLiveReloadServer optionalLiveReloadServer() { + return new OptionalLiveReloadServer(this.liveReloadServer); + } + + final ExecutorService getExecutor() { + return this.executor; + } + + } + + /** + * Client configuration for remote update and restarts. + */ + @ConditionalOnProperty(prefix = "spring.developertools.remote.restart", name = "enabled", matchIfMissing = true) + static class RemoteRestartClientConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Value("${remoteUrl}") + private String remoteUrl; + + @Bean + public ClassPathFileSystemWatcher classPathFileSystemWatcher() { + DefaultRestartInitializer restartInitializer = new DefaultRestartInitializer(); + URL[] urls = restartInitializer.getInitialUrls(Thread.currentThread()); + if (urls == null) { + urls = new URL[0]; + } + return new ClassPathFileSystemWatcher(classPathRestartStrategy(), urls); + } + + @Bean + public ClassPathRestartStrategy classPathRestartStrategy() { + return new PatternClassPathRestartStrategy(this.properties.getRestart() + .getExclude()); + } + + @Bean + public ClassPathChangeUploader classPathChangeUploader( + ClientHttpRequestFactory requestFactory) { + String url = this.remoteUrl + this.properties.getRemote().getContextPath() + + "/restart"; + return new ClassPathChangeUploader(url, requestFactory); + } + + } + + /** + * Client configuration for remote debug HTTP tunneling. + */ + @ConditionalOnProperty(prefix = "spring.developertools.remote.debug", name = "enabled", matchIfMissing = true) + @ConditionalOnClass(Filter.class) + @Conditional(LocalDebugPortAvailableCondition.class) + static class RemoteDebugTunnelClientConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Value("${remoteUrl}") + private String remoteUrl; + + @Bean + public TunnelClient remoteDebugTunnelClient( + ClientHttpRequestFactory requestFactory) { + RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote(); + String url = this.remoteUrl + remoteProperties.getContextPath() + "/debug"; + TunnelConnection connection = new HttpTunnelConnection(url, requestFactory); + int localPort = remoteProperties.getDebug().getLocalPort(); + TunnelClient client = new TunnelClient(localPort, connection); + client.addListener(new LoggingTunnelClientListener()); + return client; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/package-info.java new file mode 100644 index 00000000000..d602593b8c3 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Client support for a remotely running Spring Boot application. + */ +package org.springframework.boot.developertools.remote.client; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/AccessManager.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/AccessManager.java new file mode 100644 index 00000000000..3922a437cea --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/AccessManager.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import org.springframework.http.server.ServerHttpRequest; + +/** + * Provides access control for a {@link Dispatcher}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface AccessManager { + + /** + * {@link AccessManager} that permits all requests. + */ + public static final AccessManager PERMIT_ALL = new AccessManager() { + + @Override + public boolean isAllowed(ServerHttpRequest request) { + return true; + } + + }; + + /** + * Determine if the specific request is allowed to be handled by the + * {@link Dispatcher}. + * @param request the request to check + * @return {@code true} if access is allowed. + */ + boolean isAllowed(ServerHttpRequest request); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Dispatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Dispatcher.java new file mode 100644 index 00000000000..8bb3239de89 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Dispatcher.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * Dispatcher used to route incoming remote server requests to a {@link Handler}. Similar + * to {@code DispatchServlet} in Spring MVC but separate to ensure that remote support can + * be used regardless of any web framework. + * + * @author Phillip Webb + * @since 1.3.0 + * @see HandlerMapper + */ +public class Dispatcher { + + private final AccessManager accessManager; + + private final List mappers; + + public Dispatcher(AccessManager accessManager, Collection mappers) { + Assert.notNull(accessManager, "AccessManager must not be null"); + Assert.notNull(mappers, "Mappers must not be null"); + this.accessManager = accessManager; + this.mappers = new ArrayList(mappers); + AnnotationAwareOrderComparator.sort(this.mappers); + } + + /** + * Dispatch the specified request to an appropriate {@link Handler}. + * @param request the request + * @param response the response + * @return {@code true} if the request was dispatched + * @throws IOException + */ + public boolean handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + for (HandlerMapper mapper : this.mappers) { + Handler handler = mapper.getHandler(request); + if (handler != null) { + handle(handler, request, response); + return true; + } + } + return false; + } + + private void handle(Handler handler, ServerHttpRequest request, + ServerHttpResponse response) throws IOException { + if (!this.accessManager.isAllowed(request)) { + response.setStatusCode(HttpStatus.FORBIDDEN); + return; + } + handler.handle(request, response); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/DispatcherFilter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/DispatcherFilter.java new file mode 100644 index 00000000000..4fb157dbf04 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/DispatcherFilter.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.util.Assert; + +/** + * Servlet filter providing integration with the remote server {@link Dispatcher}. + * + * @author Phillip Webb + * @author Rob Winch + * @since 1.3.0 + */ +public class DispatcherFilter implements Filter { + + private final Dispatcher dispatcher; + + public DispatcherFilter(Dispatcher dispatcher) { + Assert.notNull(dispatcher, "Dispatcher must not be null"); + this.dispatcher = dispatcher; + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + if (request instanceof HttpServletRequest + && response instanceof HttpServletResponse) { + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + else { + chain.doFilter(request, response); + } + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws IOException, ServletException { + ServerHttpRequest serverRequest = new ServletServerHttpRequest(request); + ServerHttpResponse serverResponse = new ServletServerHttpResponse(response); + if (!this.dispatcher.handle(serverRequest, serverResponse)) { + chain.doFilter(request, response); + } + } + + @Override + public void destroy() { + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Handler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Handler.java new file mode 100644 index 00000000000..b32b2062e3f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/Handler.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; + +/** + * A single handler that is able to process an incoming remote server request. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface Handler { + + /** + * Handle the request. + * @param request the request + * @param response the response + * @throws IOException + */ + void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException; + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HandlerMapper.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HandlerMapper.java new file mode 100644 index 00000000000..8b1881da911 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HandlerMapper.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import org.springframework.http.server.ServerHttpRequest; + +/** + * Interface to provide a mapping between a {@link ServerHttpRequest} and a + * {@link Handler}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface HandlerMapper { + + /** + * Return the handler for the given request or {@code null}. + * @param request the request + * @return a {@link Handler} or {@code null} + */ + Handler getHandler(ServerHttpRequest request); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpHeaderAccessManager.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpHeaderAccessManager.java new file mode 100644 index 00000000000..e5ab6f61f84 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpHeaderAccessManager.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; + +/** + * {@link AccessManager} that checks for the presence of a HTTP header secret. + * + * @author Rob Winch + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpHeaderAccessManager implements AccessManager { + + private final String headerName; + + private final String expectedSecret; + + public HttpHeaderAccessManager(String headerName, String expectedSecret) { + Assert.hasLength(headerName, "HeaderName must not be empty"); + Assert.hasLength(expectedSecret, "ExpectedSecret must not be empty"); + this.headerName = headerName; + this.expectedSecret = expectedSecret; + } + + @Override + public boolean isAllowed(ServerHttpRequest request) { + String providedSecret = request.getHeaders().getFirst(this.headerName); + return this.expectedSecret.equals(providedSecret); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpStatusHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpStatusHandler.java new file mode 100644 index 00000000000..76d0f50e16d --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/HttpStatusHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * {@link Handler} that responds with a specific {@link HttpStatus}. + * + * @author Phillip Webb + */ +public class HttpStatusHandler implements Handler { + + private final HttpStatus status; + + /** + * Create a new {@link HttpStatusHandler} instance that will respond with a HTTP OK 200 + * status. + */ + public HttpStatusHandler() { + this(HttpStatus.OK); + } + + /** + * Create a new {@link HttpStatusHandler} instance that will respond with the specified + * status. + * @param status the status + */ + public HttpStatusHandler(HttpStatus status) { + Assert.notNull(status, "Status must not be null"); + this.status = status; + } + + @Override + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + response.setStatusCode(this.status); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/UrlHandlerMapper.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/UrlHandlerMapper.java new file mode 100644 index 00000000000..64a9b7ba479 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/UrlHandlerMapper.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.remote.server; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; + +/** + * {@link HandlerMapper} implementation that maps incoming URLs + * + * @author Rob Winch + * @author Phillip Webb + * @since 1.3.0 + */ +public class UrlHandlerMapper implements HandlerMapper { + + private final String requestUri; + + private final Handler hander; + + /** + * Create a new {@link UrlHandlerMapper}. + * @param url the URL to map + * @param handler the handler to use + */ + public UrlHandlerMapper(String url, Handler handler) { + Assert.hasLength(url, "URL must not be empty"); + Assert.isTrue(url.startsWith("/"), "URL must start with '/'"); + this.requestUri = url; + this.hander = handler; + } + + @Override + public Handler getHandler(ServerHttpRequest request) { + if (this.requestUri.equals(request.getURI().getPath())) { + return this.hander; + } + return null; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/package-info.java new file mode 100644 index 00000000000..b4e66352ddc --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/server/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Server support for a remotely running Spring Boot application. + */ +package org.springframework.boot.developertools.remote.server; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java new file mode 100644 index 00000000000..53ea59c1766 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +/** + * A filtered collections of URLs which can be change after the application has started. + * + * @author Phillip Webb + */ +class ChangeableUrls implements Iterable { + + private static final String[] SKIPPED_PROJECTS = { "spring-boot", + "spring-boot-developer-tools", "spring-boot-autoconfigure", + "spring-boot-actuator", "spring-boot-starter" }; + + private static final Pattern STARTER_PATTERN = Pattern + .compile("\\/spring-boot-starter-[\\w-]+\\/"); + + private final List urls; + + private ChangeableUrls(URL... urls) { + List reloadableUrls = new ArrayList(urls.length); + for (URL url : urls) { + if (isReloadable(url)) { + reloadableUrls.add(url); + } + } + this.urls = Collections.unmodifiableList(reloadableUrls); + } + + private boolean isReloadable(URL url) { + String urlString = url.toString(); + return isFolderUrl(urlString) && !isSkipped(urlString); + } + + private boolean isFolderUrl(String urlString) { + return urlString.startsWith("file:") && urlString.endsWith("/"); + } + + private boolean isSkipped(String urlString) { + // Skip certain spring-boot projects to allow them to be imported in the same IDE + for (String skipped : SKIPPED_PROJECTS) { + if (urlString.contains("/" + skipped + "/target/classes/")) { + return true; + } + } + // Skip all starter projects + if (STARTER_PATTERN.matcher(urlString).find()) { + return true; + } + return false; + } + + @Override + public Iterator iterator() { + return this.urls.iterator(); + } + + public int size() { + return this.urls.size(); + } + + public URL[] toArray() { + return this.urls.toArray(new URL[this.urls.size()]); + } + + public List toList() { + return Collections.unmodifiableList(this.urls); + } + + @Override + public String toString() { + return this.urls.toString(); + } + + public static ChangeableUrls fromUrlClassLoader(URLClassLoader classLoader) { + return fromUrls(classLoader.getURLs()); + } + + public static ChangeableUrls fromUrls(Collection urls) { + return fromUrls(new ArrayList(urls).toArray(new URL[urls.size()])); + } + + public static ChangeableUrls fromUrls(URL... urls) { + return new ChangeableUrls(urls); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java new file mode 100644 index 00000000000..96cfea1e51e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional} that only matches when the {@link RestartInitializer} has been + * applied with non {@code null} URLs. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(OnInitializedRestarterCondition.class) +public @interface ConditionalOnInitializedRestarter { + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java new file mode 100644 index 00000000000..0bd54b8b6d8 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Default {@link RestartInitializer} that only enable initial restart when running a + * standard "main" method. Skips initialization when running "fat" jars (included + * exploded) or when running from a test. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultRestartInitializer implements RestartInitializer { + + private static final Set SKIPPED_STACK_ELEMENTS; + static { + Set skipped = new LinkedHashSet(); + skipped.add("org.junit.runners."); + skipped.add("org.springframework.boot.test."); + SKIPPED_STACK_ELEMENTS = Collections.unmodifiableSet(skipped); + } + + @Override + public URL[] getInitialUrls(Thread thread) { + if (!isMain(thread)) { + return null; + } + for (StackTraceElement element : thread.getStackTrace()) { + if (isSkippedStackElement(element)) { + return null; + } + } + return getUrls(thread); + } + + /** + * Returns if the thread is for a main invocation. By default checks the name of the + * thread and the context classloader. + * @param thread the thread to check + * @return {@code true} if the thread is a main invocation + */ + protected boolean isMain(Thread thread) { + return thread.getName().equals("main") + && thread.getContextClassLoader().getClass().getName() + .contains("AppClassLoader"); + } + + /** + * Checks if a specific {@link StackTraceElement} should cause the initializer to be + * skipped. + * @param element the stack element to check + * @return {@code true} if the stack element means that the initializer should be + * skipped + */ + protected boolean isSkippedStackElement(StackTraceElement element) { + for (String skipped : SKIPPED_STACK_ELEMENTS) { + if (element.getClassName().startsWith(skipped)) { + return true; + } + } + return false; + } + + /** + * Return the URLs that should be used with initialization. + * @param thread the source thread + * @return the URLs + */ + protected URL[] getUrls(Thread thread) { + return ChangeableUrls.fromUrlClassLoader( + (URLClassLoader) thread.getContextClassLoader()).toArray(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java new file mode 100644 index 00000000000..825f844a6e2 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import org.springframework.util.Assert; + +/** + * The "main" method located from a running thread. + * + * @author Phillip Webb + */ +class MainMethod { + + private final Method method; + + public MainMethod() { + this(Thread.currentThread()); + } + + public MainMethod(Thread thread) { + Assert.notNull(thread, "Thread must not be null"); + this.method = getMainMethod(thread); + } + + private Method getMainMethod(Thread thread) { + for (StackTraceElement element : thread.getStackTrace()) { + if ("main".equals(element.getMethodName())) { + Method method = getMainMethod(element); + if (method != null) { + return method; + } + } + } + throw new IllegalStateException("Unable to find main method"); + } + + private Method getMainMethod(StackTraceElement element) { + try { + Class elementClass = Class.forName(element.getClassName()); + Method method = elementClass.getDeclaredMethod("main", String[].class); + if (Modifier.isStatic(method.getModifiers())) { + return method; + } + } + catch (Exception ex) { + // Ignore + } + return null; + } + + /** + * Returns the actual main method. + * @return the main method + */ + public Method getMethod() { + return this.method; + } + + /** + * Return the name of the declaring class. + * @return the declaring class name + */ + public String getDeclaringClassName() { + return this.method.getDeclaringClass().getName(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java new file mode 100644 index 00000000000..28eb21bd9e2 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link Condition} that checks that a {@link Restarter} is available an initialized. + * + * @author Phillip Webb + * @see ConditionalOnInitializedRestarter + */ +class OnInitializedRestarterCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, + AnnotatedTypeMetadata metadata) { + Restarter restarter = getRestarter(); + if (restarter == null) { + return ConditionOutcome.noMatch("Restarter unavailable"); + } + if (restarter.getInitialUrls() == null) { + return ConditionOutcome.noMatch("Restarter initialized without URLs"); + } + return ConditionOutcome.match("Restarter available and initialized"); + } + + private Restarter getRestarter() { + try { + return Restarter.getInstance(); + } + catch (Exception ex) { + return null; + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java new file mode 100644 index 00000000000..4bb0ebda399 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.Ordered; + +/** + * {@link ApplicationListener} to initialize the {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see Restarter + */ +public class RestartApplicationListener implements ApplicationListener, + Ordered { + + private int order = HIGHEST_PRECEDENCE; + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (event instanceof ApplicationStartedEvent) { + Restarter.initialize(((ApplicationStartedEvent) event).getArgs()); + } + if (event instanceof ApplicationReadyEvent + || event instanceof ApplicationFailedEvent) { + Restarter.getInstance().finish(); + } + } + + @Override + public int getOrder() { + return this.order; + } + + /** + * Set the order of the listener. + * @param order the order of the listener + */ + public void setOrder(int order) { + this.order = order; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java new file mode 100644 index 00000000000..36692dadd5c --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.net.URL; + +/** + * Strategy interface used to initialize a {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see DefaultRestartInitializer + */ +public interface RestartInitializer { + + /** + * {@link RestartInitializer} that doesn't return any URLs. + */ + public static final RestartInitializer NONE = new RestartInitializer() { + + @Override + public URL[] getInitialUrls(Thread thread) { + return null; + } + + }; + + /** + * Return the initial set of URLs for the {@link Restarter} or {@code null} if no + * initial restart is required. + * @param thread the source thread + * @return initial URLs or {@code null} + */ + URL[] getInitialUrls(Thread thread); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java new file mode 100644 index 00000000000..7b5a9d7d239 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; + +/** + * Thread used to launch a restarted application. + * + * @author Phillip Webb + */ +class RestartLauncher extends Thread { + + private final String mainClassName; + + private final String[] args; + + public RestartLauncher(ClassLoader classLoader, String mainClassName, String[] args, + UncaughtExceptionHandler exceptionHandler) { + this.mainClassName = mainClassName; + this.args = args; + setName("restartedMain"); + setUncaughtExceptionHandler(exceptionHandler); + setDaemon(false); + setContextClassLoader(classLoader); + } + + @Override + public void run() { + try { + Class mainClass = getContextClassLoader().loadClass(this.mainClassName); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.invoke(null, new Object[] { this.args }); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScope.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScope.java new file mode 100644 index 00000000000..557d5287936 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScope.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Scope; + +/** + * Restart {@code @Scope} Annotation used to indicate that a bean shoul remain beteen + * restarts. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartScopeInitializer + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Scope("restart") +public @interface RestartScope { + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScopeInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScopeInitializer.java new file mode 100644 index 00000000000..ca7a133c001 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartScopeInitializer.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.config.Scope; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Support for a 'restart' {@link Scope} that allows beans to remain between restarts. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class RestartScopeInitializer implements + ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.getBeanFactory().registerScope("restart", new RestartScope()); + } + + /** + * {@link Scope} that stores beans as {@link Restarter} attributes. + */ + private static class RestartScope implements Scope { + + @Override + public Object get(String name, ObjectFactory objectFactory) { + return Restarter.getInstance().getOrAddAttribute(name, objectFactory); + } + + @Override + public Object remove(String name) { + return Restarter.getInstance().removeAttribute(name); + } + + @Override + public void registerDestructionCallback(String name, Runnable callback) { + } + + @Override + public Object resolveContextualObject(String key) { + return null; + } + + @Override + public String getConversationId() { + return null; + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java new file mode 100644 index 00000000000..90fc2a66901 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java @@ -0,0 +1,568 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.beans.Introspector; +import java.lang.Thread.UncaughtExceptionHandler; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.CachedIntrospectionResults; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.boot.developertools.restart.classloader.RestartClassLoader; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.cglib.core.ClassNameReader; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Allows a running application to be restarted with an updated classpath. The restarter + * works by creating a new application ClassLoader that is split into two parts. The top + * part contains static URLs that don't change (for example 3rd party libraries and Spring + * Boot itself) and the bottom part contains URLs where classes and resources might be + * updated. + *

+ * The Restarter should be {@link #initialize(String[]) initialized} early to ensure that + * classes are loaded multiple times. Mostly the {@link RestartApplicationListener} can be + * relied upon to perform initialization, however, you may need to call + * {@link #initialize(String[])} directly if your SpringApplication arguments are not + * identical to your main method arguments. + *

+ * By default, applications running in an IDE (i.e. those not packaged as "fat jars") will + * automatically detect URLs that can change. It's also possible to manually configure + * URLs or class file updates for remote restart scenarios. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartApplicationListener + * @see #initialize(String[]) + * @see #getInstance() + * @see #restart() + */ +public class Restarter { + + private static Restarter instance; + + private Log logger = new DeferredLog(); + + private final boolean forceReferenceCleanup; + + private URL[] initialUrls; + + private final String mainClassName; + + private final ClassLoader applicationClassLoader; + + private final String[] args; + + private final UncaughtExceptionHandler exceptionHandler; + + private final Set urls = new LinkedHashSet(); + + private final ClassLoaderFiles classLoaderFiles = new ClassLoaderFiles(); + + private final Map attributes = new HashMap(); + + private final BlockingDeque leakSafeThreads = new LinkedBlockingDeque(); + + private boolean finished = false; + + private Lock stopLock = new ReentrantLock(); + + /** + * Internal constructor to create a new {@link Restarter} instance. + * @param thread the source thread + * @param args the application arguments + * @param forceReferenceCleanup if soft/weak reference cleanup should be forced + * @param initializer + * @see #initialize(String[]) + */ + protected Restarter(Thread thread, String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + Assert.notNull(thread, "Thread must not be null"); + Assert.notNull(args, "Args must not be null"); + Assert.notNull(initializer, "Initializer must not be null"); + this.logger.debug("Creating new Restarter for thread " + thread); + SilentExitExceptionHandler.setup(thread); + this.forceReferenceCleanup = forceReferenceCleanup; + this.initialUrls = initializer.getInitialUrls(thread); + this.mainClassName = getMainClassName(thread); + this.applicationClassLoader = thread.getContextClassLoader(); + this.args = args; + this.exceptionHandler = thread.getUncaughtExceptionHandler(); + this.leakSafeThreads.add(new LeakSafeThread()); + } + + private String getMainClassName(Thread thread) { + try { + return new MainMethod(thread).getDeclaringClassName(); + } + catch (Exception ex) { + return null; + } + } + + protected void initialize(boolean restartOnInitialize) { + preInitializeLeakyClasses(); + if (this.initialUrls != null) { + this.urls.addAll(Arrays.asList(this.initialUrls)); + if (restartOnInitialize) { + this.logger.debug("Immediately restarting application"); + immediateRestart(); + } + } + } + + private void immediateRestart() { + try { + getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Void call() throws Exception { + start(); + return null; + } + + }); + } + catch (Exception ex) { + this.logger.warn("Unable to initialize restarter", ex); + } + SilentExitExceptionHandler.exitCurrentThread(); + } + + /** + * CGLIB has a private exception field which needs to initialized early to ensure that + * the stacktrace doesn't retain a reference to the RestartClassLoader. + */ + private void preInitializeLeakyClasses() { + try { + Class readerClass = ClassNameReader.class; + Field field = readerClass.getDeclaredField("EARLY_EXIT"); + field.setAccessible(true); + ((Throwable) field.get(null)).fillInStackTrace(); + } + catch (Exception ex) { + this.logger.warn("Unable to pre-initialize classes", ex); + } + } + + /** + * Add additional URLs to be includes in the next restart. + * @param urls the urls to add + */ + public void addUrls(Collection urls) { + Assert.notNull(urls, "Urls must not be null"); + this.urls.addAll(ChangeableUrls.fromUrls(urls).toList()); + } + + /** + * Add additional {@link ClassLoaderFiles} to be included in the next restart. + * @param classLoaderFiles the files to add + */ + public void addClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { + Assert.notNull(classLoaderFiles, "ClassLoaderFiles must not be null"); + this.classLoaderFiles.addAll(classLoaderFiles); + } + + /** + * Return a {@link ThreadFactory} that can be used to create leak safe threads. + * @return a leak safe thread factory + */ + public ThreadFactory getThreadFactory() { + return new LeakSafeThreadFactory(); + } + + /** + * Restart the running application. + */ + public void restart() { + this.logger.debug("Restarting application"); + getLeakSafeThread().call(new Callable() { + + @Override + public Void call() throws Exception { + Restarter.this.stop(); + Restarter.this.start(); + return null; + } + + }); + } + + /** + * Start the application. + * @throws Exception + */ + protected void start() throws Exception { + Assert.notNull(this.mainClassName, "Unable to find the main class to restart"); + ClassLoader parent = this.applicationClassLoader; + URL[] urls = this.urls.toArray(new URL[this.urls.size()]); + ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles); + ClassLoader classLoader = new RestartClassLoader(parent, urls, updatedFiles, + this.logger); + if (this.logger.isDebugEnabled()) { + this.logger.debug("Starting application " + this.mainClassName + + " with URLs " + Arrays.asList(urls)); + } + relaunch(classLoader); + } + + /** + * Relaunch the application using the specified classloader. + * @param classLoader the classloader to use + * @throws Exception + */ + protected void relaunch(ClassLoader classLoader) throws Exception { + RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, + this.args, this.exceptionHandler); + launcher.start(); + launcher.join(); + } + + /** + * Stop the application. + * @throws Exception + */ + protected void stop() throws Exception { + this.logger.debug("Stopping application"); + this.stopLock.lock(); + try { + triggerShutdownHooks(); + cleanupCaches(); + if (this.forceReferenceCleanup) { + forceReferenceCleanup(); + } + } + finally { + this.stopLock.unlock(); + } + System.gc(); + System.runFinalization(); + } + + @SuppressWarnings("rawtypes") + private void triggerShutdownHooks() throws Exception { + Class hooksClass = Class.forName("java.lang.ApplicationShutdownHooks"); + Method runHooks = hooksClass.getDeclaredMethod("runHooks"); + runHooks.setAccessible(true); + runHooks.invoke(null); + Field field = hooksClass.getDeclaredField("hooks"); + field.setAccessible(true); + field.set(null, new IdentityHashMap()); + } + + private void cleanupCaches() throws Exception { + Introspector.flushCaches(); + cleanupKnownCaches(); + } + + private void cleanupKnownCaches() throws Exception { + // Whilst not strictly necessary it helps to cleanup soft reference caches + // early rather than waiting for memory limits to be reached + clear(ResolvableType.class, "cache"); + clear("org.springframework.core.SerializableTypeWrapper", "cache"); + clear(CachedIntrospectionResults.class, "acceptedClassLoaders"); + clear(CachedIntrospectionResults.class, "strongClassCache"); + clear(CachedIntrospectionResults.class, "softClassCache"); + clear(ReflectionUtils.class, "declaredFieldsCache"); + clear(ReflectionUtils.class, "declaredMethodsCache"); + clear(AnnotationUtils.class, "findAnnotationCache"); + clear(AnnotationUtils.class, "annotatedInterfaceCache"); + clear("com.sun.naming.internal.ResourceManager", "propertiesCache"); + } + + private void clear(String className, String fieldName) { + try { + clear(Class.forName(className), fieldName); + } + catch (Exception ex) { + this.logger.debug("Unable to clear field " + className + " " + fieldName, ex); + } + } + + private void clear(Class type, String fieldName) throws Exception { + Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + Object instance = field.get(null); + if (instance instanceof Set) { + ((Set) instance).clear(); + } + if (instance instanceof Map) { + Map map = ((Map) instance); + for (Iterator iterator = map.keySet().iterator(); iterator.hasNext();) { + Object value = iterator.next(); + if (value instanceof Class + && ((Class) value).getClassLoader() instanceof RestartClassLoader) { + iterator.remove(); + } + + } + } + } + + /** + * Cleanup any soft/weak references by forcing an {@link OutOfMemoryError} error. + */ + private void forceReferenceCleanup() { + try { + final List memory = new LinkedList(); + while (true) { + memory.add(new long[102400]); + } + } + catch (final OutOfMemoryError ex) { + } + } + + /** + * Called to finish {@link Restarter} initialization when application logging is + * available. + */ + synchronized void finish() { + if (!isFinished()) { + this.logger = DeferredLog.replay(this.logger, LogFactory.getLog(getClass())); + this.finished = true; + } + } + + boolean isFinished() { + return this.finished; + } + + private LeakSafeThread getLeakSafeThread() { + try { + return this.leakSafeThreads.takeFirst(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + public Object getOrAddAttribute(final String name, + final ObjectFactory objectFactory) { + synchronized (this.attributes) { + if (!this.attributes.containsKey(name)) { + this.attributes.put(name, objectFactory.getObject()); + } + return this.attributes.get(name); + } + } + + public Object removeAttribute(String name) { + synchronized (this.attributes) { + return this.attributes.remove(name); + } + } + + /** + * Return the initial set of URLs as configured by the {@link RestartInitializer}. + * @return the initial URLs or {@code null} + */ + public URL[] getInitialUrls() { + return this.initialUrls; + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args) { + initialize(args, false, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, RestartInitializer initializer) { + initialize(args, false, initializer, true); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup) { + initialize(args, forceReferenceCleanup, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + initialize(args, forceReferenceCleanup, initializer, true); + } + + /** + * Initialize restart support for the current application. Called automatically by + * {@link RestartApplicationListener} but can also be called directly if main + * application arguments are not the same as those passed to the + * {@link SpringApplication}. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * each restart. This will slow down restarts and is intended primarily for testing + * @param initializer the restart initializer + * @param restartOnInitialize if the restarter should be restarted immediately when + * the {@link RestartInitializer} returns non {@code null} results + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer, boolean restartOnInitialize) { + if (instance == null) { + synchronized (Restarter.class) { + instance = new Restarter(Thread.currentThread(), args, + forceReferenceCleanup, initializer); + } + instance.initialize(restartOnInitialize); + } + } + + /** + * Return the active {@link Restarter} instance. Cannot be called before + * {@link #initialize(String[]) initialization}. + * @return the restarter + */ + public synchronized static Restarter getInstance() { + Assert.state(instance != null, "Restarter has not been initialized"); + return instance; + } + + /** + * Set the restarter instance (useful for testing). + * @param instance the instance to set + */ + final static void setInstance(Restarter instance) { + Restarter.instance = instance; + } + + /** + * Clear the instance. Primarily provided for tests and not usually used in + * application code. + */ + public static void clearInstance() { + instance = null; + } + + /** + * Thread that is created early so not to retain the {@link RestartClassLoader}. + */ + private class LeakSafeThread extends Thread { + + private Callable callable; + + private Object result; + + public LeakSafeThread() { + setDaemon(false); + } + + public void call(Callable callable) { + this.callable = callable; + start(); + } + + @SuppressWarnings("unchecked") + public V callAndWait(Callable callable) { + this.callable = callable; + start(); + try { + join(); + return (V) this.result; + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + @Override + public void run() { + // We are safe to refresh the ActionThread (and indirectly call + // AccessController.getContext()) since our stack doesn't include the + // RestartClassLoader + try { + Restarter.this.leakSafeThreads.put(new LeakSafeThread()); + this.result = this.callable.call(); + } + catch (Exception ex) { + ex.printStackTrace(); + System.exit(1); + } + } + + } + + /** + * {@link ThreadFactory} that creates a leak safe thead. + */ + private class LeakSafeThreadFactory implements ThreadFactory { + + @Override + public Thread newThread(final Runnable runnable) { + return getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Thread call() throws Exception { + Thread thread = new Thread(runnable); + thread.setContextClassLoader(Restarter.this.applicationClassLoader); + return thread; + } + + }); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java new file mode 100644 index 00000000000..04bf3e70864 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2014 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart; + +import java.lang.Thread.UncaughtExceptionHandler; + +/** + * {@link UncaughtExceptionHandler} decorator that allows a thread to exit silently. + * + * @author Phillip Webb + */ +class SilentExitExceptionHandler implements UncaughtExceptionHandler { + + private final UncaughtExceptionHandler delegate; + + public SilentExitExceptionHandler(UncaughtExceptionHandler delegate) { + this.delegate = delegate; + } + + @Override + public void uncaughtException(Thread thread, Throwable exception) { + if (exception instanceof SilentExitException) { + return; + } + if (this.delegate != null) { + this.delegate.uncaughtException(thread, exception); + } + } + + public static void setup(Thread thread) { + UncaughtExceptionHandler handler = thread.getUncaughtExceptionHandler(); + if (!(handler instanceof SilentExitExceptionHandler)) { + handler = new SilentExitExceptionHandler(handler); + thread.setUncaughtExceptionHandler(handler); + } + } + + public static void exitCurrentThread() { + throw new SilentExitException(); + } + + private static class SilentExitException extends RuntimeException { + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java new file mode 100644 index 00000000000..9a5c2c8d1e0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.classloader; + +import java.io.Serializable; + +import org.springframework.util.Assert; + +/** + * A single file that may be served from a {@link ClassLoader}. Can be used to represent + * files that have been added, modified or deleted since the original JAR was created. + * + * @author Phillip Webb + * @see ClassLoaderFileRepository + * @since 1.3.0 + */ +public class ClassLoaderFile implements Serializable { + + private static final long serialVersionUID = 1; + + private final Kind kind; + + private final byte[] contents; + + private final long lastModified; + + /** + * Create a new {@link ClassLoaderFile} instance. + * @param kind the kind of file + * @param contents the file contents + */ + public ClassLoaderFile(Kind kind, byte[] contents) { + this(kind, System.currentTimeMillis(), contents); + } + + /** + * Create a new {@link ClassLoaderFile} instance. + * @param kind the kind of file + * @param lastModified the last modified time + * @param contents the file contents + */ + public ClassLoaderFile(Kind kind, long lastModified, byte[] contents) { + Assert.notNull(kind, "Kind must not be null"); + Assert.isTrue(kind == Kind.DELETED ? contents == null : contents != null, + "Contents must " + (kind == Kind.DELETED ? "" : "not ") + "be null"); + this.kind = kind; + this.lastModified = lastModified; + this.contents = contents; + } + + /** + * Return the file {@link Kind} (added, modified, deleted). + * @return the kind + */ + public Kind getKind() { + return this.kind; + } + + /** + * Return the time that the file was last modified. + * @return the last modified time + */ + public long getLastModified() { + return this.lastModified; + } + + /** + * Return the contents of the file as a byte array or {@code null} if + * {@link #getKind()} is {@link Kind#DELETED}. + * @return the contents or {@code null} + */ + public byte[] getContents() { + return this.contents; + } + + /** + * The kinds of class load files. + */ + public static enum Kind { + + /** + * The file has been added since the original JAR was created. + */ + ADDED, + + /** + * The file has been modified since the original JAR was created. + */ + MODIFIED, + + /** + * The file has been deleted since the original JAR was created. + */ + DELETED + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java new file mode 100644 index 00000000000..e2c08e88b8d --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.classloader; + +/** + * A container for files that may be served from a {@link ClassLoader}. Can be used to + * represent files that have been added, modified or deleted since the original JAR was + * created. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassLoaderFile + */ +public interface ClassLoaderFileRepository { + + /** + * Empty {@link ClassLoaderFileRepository} implementation. + */ + public static final ClassLoaderFileRepository NONE = new ClassLoaderFileRepository() { + + @Override + public ClassLoaderFile getFile(String name) { + return null; + } + + }; + + /** + * Return a {@link ClassLoaderFile} for the given name or {@code null} if no file is + * contained in this collection. + * @param name the name of the file + * @return a {@link ClassLoaderFile} or {@code null} + */ + ClassLoaderFile getFile(String name); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java new file mode 100644 index 00000000000..2b7fbacc371 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.classloader; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} for the contents of a {@link ClassLoaderFile}. + * + * @author Phillip Webb + */ +class ClassLoaderFileURLStreamHandler extends URLStreamHandler { + + private ClassLoaderFile file; + + public ClassLoaderFileURLStreamHandler(ClassLoaderFile file) { + this.file = file; + } + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return new Connection(url); + } + + private class Connection extends URLConnection { + + public Connection(URL url) { + super(url); + } + + @Override + public void connect() throws IOException { + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream( + ClassLoaderFileURLStreamHandler.this.file.getContents()); + } + + @Override + public long getLastModified() { + return ClassLoaderFileURLStreamHandler.this.file.getLastModified(); + + } + + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java new file mode 100644 index 00000000000..c4c89e86b79 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java @@ -0,0 +1,202 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.classloader; + +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import javax.management.loading.ClassLoaderRepository; + +import org.springframework.util.Assert; + +/** + * {@link ClassLoaderFileRepository} that maintains a collection of + * {@link ClassLoaderFile} items grouped by source folders. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassLoaderFile + * @see ClassLoaderRepository + */ +public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable { + + private static final long serialVersionUID = 1; + + private final Map sourceFolders; + + /** + * Create a new {@link ClassLoaderFiles} instance. + */ + public ClassLoaderFiles() { + this.sourceFolders = new LinkedHashMap(); + } + + /** + * Create a new {@link ClassLoaderFiles} instance. + * @param classLoaderFiles the source classloader files. + */ + public ClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { + Assert.notNull(classLoaderFiles, "ClassLoaderFiles must not be null"); + this.sourceFolders = new LinkedHashMap( + classLoaderFiles.sourceFolders); + } + + /** + * Add all elements items from the specified {@link ClassLoaderFiles} to this + * instance. + * @param files the files to add + */ + public void addAll(ClassLoaderFiles files) { + Assert.notNull(files, "Files must not be null"); + for (SourceFolder folder : files.getSourceFolders()) { + for (Map.Entry entry : folder.getFilesEntrySet()) { + addFile(folder.getName(), entry.getKey(), entry.getValue()); + } + } + } + + /** + * Add a single {@link ClassLoaderFile} to the collection. + * @param name the name of the file + * @param file the file to add + */ + public void addFile(String name, ClassLoaderFile file) { + addFile("", name, file); + } + + /** + * Add a single {@link ClassLoaderFile} to the collection. + * @param sourceFolder the source folder of the file + * @param name the name of the file + * @param file the file to add + */ + public void addFile(String sourceFolder, String name, ClassLoaderFile file) { + Assert.notNull(sourceFolder, "SourceFolder must not be null"); + Assert.notNull(name, "Name must not be null"); + Assert.notNull(file, "File must not be null"); + removeAll(name); + getOrCreateSourceFolder(sourceFolder).add(name, file); + } + + private void removeAll(String name) { + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + sourceFolder.remove(name); + } + } + + /** + * Get or create a {@link SourceFolder} with the given name. + * @param name the name of the folder + * @return an existing or newly added {@link SourceFolder} + */ + protected final SourceFolder getOrCreateSourceFolder(String name) { + SourceFolder sourceFolder = this.sourceFolders.get(name); + if (sourceFolder == null) { + sourceFolder = new SourceFolder(name); + this.sourceFolders.put(name, sourceFolder); + } + return sourceFolder; + } + + /** + * Return all {@link SourceFolder SourceFolders} that have been added to the + * collection. + * @return a collection of {@link SourceFolder} items + */ + public Collection getSourceFolders() { + return Collections.unmodifiableCollection(this.sourceFolders.values()); + } + + /** + * Return the size of the collection. + * @return the size of the collection + */ + public int size() { + int size = 0; + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + size += sourceFolder.getFiles().size(); + } + return size; + } + + @Override + public ClassLoaderFile getFile(String name) { + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + ClassLoaderFile file = sourceFolder.get(name); + if (file != null) { + return file; + } + } + return null; + } + + /** + * An individual source folder that is being managed by the collection. + */ + public static class SourceFolder implements Serializable { + + private static final long serialVersionUID = 1; + + private final String name; + + private final Map files = new LinkedHashMap(); + + SourceFolder(String name) { + this.name = name; + } + + public Set> getFilesEntrySet() { + return this.files.entrySet(); + } + + protected final void add(String name, ClassLoaderFile file) { + this.files.put(name, file); + } + + protected final void remove(String name) { + this.files.remove(name); + } + + protected final ClassLoaderFile get(String name) { + return this.files.get(name); + } + + /** + * Return the name of the source folder. + * @return the name of the source folder + */ + public String getName() { + return this.name; + } + + /** + * Return all {@link ClassLoaderFile ClassLoaderFiles} in the collection that are + * contained in this source folder. + * @return the files contained in the source folder + */ + public Collection getFiles() { + return Collections.unmodifiableCollection(this.files.values()); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java new file mode 100644 index 00000000000..fe35d27051e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java @@ -0,0 +1,231 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.classloader; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Enumeration; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.core.SmartClassLoader; +import org.springframework.util.Assert; + +/** + * Disposable {@link ClassLoader} used to support application restarting. Provides parent + * last loading for the specified URLs. + * + * @author Andy Clement + * @author Phillip Webb + * @since 1.3.0 + */ +public class RestartClassLoader extends URLClassLoader implements SmartClassLoader { + + private final Log logger; + + private final ClassLoaderFileRepository updatedFiles; + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader + * @param urls the urls managed by the classloader + */ + public RestartClassLoader(ClassLoader parent, URL[] urls) { + this(parent, urls, ClassLoaderFileRepository.NONE); + } + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader + * @param updatedFiles any files that have been updated since the JARs referenced in + * URLs were created. + * @param urls the urls managed by the classloader + */ + public RestartClassLoader(ClassLoader parent, URL[] urls, + ClassLoaderFileRepository updatedFiles) { + this(parent, urls, updatedFiles, LogFactory.getLog(RestartClassLoader.class)); + } + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader + * @param updatedFiles any files that have been updated since the JARs referenced in + * URLs were created. + * @param urls the urls managed by the classloader + * @param logger the logger used for messages + */ + public RestartClassLoader(ClassLoader parent, URL[] urls, + ClassLoaderFileRepository updatedFiles, Log logger) { + super(urls, parent); + Assert.notNull(parent, "Parent must not be null"); + Assert.notNull(updatedFiles, "UpdatedFiles must not be null"); + Assert.notNull(logger, "Logger must not be null"); + this.updatedFiles = updatedFiles; + this.logger = logger; + if (logger.isDebugEnabled()) { + logger.debug("Created RestartClassLoader " + toString()); + } + } + + @Override + public Enumeration getResources(String name) throws IOException { + // Use the parent since we're shadowing resource and we don't want duplicates + Enumeration resources = getParent().getResources(name); + ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file != null) { + // Assume that we're replacing just the first item + if (resources.hasMoreElements()) { + resources.nextElement(); + } + if (file.getKind() != Kind.DELETED) { + return new CompoundEnumeration(createFileUrl(name, file), resources); + } + } + return resources; + } + + @Override + public URL getResource(String name) { + ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file != null && file.getKind() == Kind.DELETED) { + return null; + } + URL resource = findResource(name); + if (resource != null) { + return resource; + } + return getParent().getResource(name); + } + + @Override + public URL findResource(final String name) { + final ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file == null) { + return super.findResource(name); + } + if (file.getKind() == Kind.DELETED) { + return null; + } + return AccessController.doPrivileged(new PrivilegedAction() { + @Override + public URL run() { + return createFileUrl(name, file); + } + }); + } + + @Override + public Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + String path = name.replace('.', '/').concat(".class"); + ClassLoaderFile file = this.updatedFiles.getFile(path); + if (file != null && file.getKind() == Kind.DELETED) { + throw new ClassNotFoundException(name); + } + Class loadedClass = findLoadedClass(name); + if (loadedClass == null) { + try { + loadedClass = findClass(name); + } + catch (ClassNotFoundException ex) { + loadedClass = getParent().loadClass(name); + } + } + if (resolve) { + resolveClass(loadedClass); + } + return loadedClass; + } + + @Override + protected Class findClass(final String name) throws ClassNotFoundException { + String path = name.replace('.', '/').concat(".class"); + final ClassLoaderFile file = this.updatedFiles.getFile(path); + if (file == null) { + return super.findClass(name); + } + if (file.getKind() == Kind.DELETED) { + throw new ClassNotFoundException(name); + } + return AccessController.doPrivileged(new PrivilegedAction>() { + @Override + public Class run() { + byte[] bytes = file.getContents(); + return defineClass(name, bytes, 0, bytes.length); + } + }); + } + + private URL createFileUrl(String name, ClassLoaderFile file) { + try { + return new URL("reloaded", null, -1, "/" + name, + new ClassLoaderFileURLStreamHandler(file)); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected void finalize() throws Throwable { + if (this.logger.isDebugEnabled()) { + this.logger.debug("Finalized classloader " + toString()); + } + super.finalize(); + } + + @Override + public boolean isClassReloadable(Class classType) { + return (classType.getClassLoader() instanceof RestartClassLoader); + } + + /** + * Compound {@link Enumeration} that adds an additional item to the front. + */ + private static class CompoundEnumeration implements Enumeration { + + private E firstElement; + + private final Enumeration enumeration; + + public CompoundEnumeration(E firstElement, Enumeration enumeration) { + this.firstElement = firstElement; + this.enumeration = enumeration; + } + + @Override + public boolean hasMoreElements() { + return (this.firstElement != null || this.enumeration.hasMoreElements()); + } + + @Override + public E nextElement() { + if (this.firstElement == null) { + return this.enumeration.nextElement(); + } + E element = this.firstElement; + this.firstElement = null; + return element; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java new file mode 100644 index 00000000000..126699d3412 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Classloaders used for reload support + */ +package org.springframework.boot.developertools.restart.classloader; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java new file mode 100644 index 00000000000..a2ae4bdaaf5 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Application restart support + */ +package org.springframework.boot.developertools.restart; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java new file mode 100644 index 00000000000..3c541413de0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/DefaultSourceFolderUrlFilter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.server; + +import java.net.URL; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link SourceFolderUrlFilter} that attempts to match URLs + * using common naming conventions. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultSourceFolderUrlFilter implements SourceFolderUrlFilter { + + private static final String[] COMMON_ENDINGS = { "/target/classes", "/bin" }; + + private static final Pattern URL_MODULE_PATTERN = Pattern.compile(".*\\/(.+)\\.jar"); + + private static final Pattern VERSION_PATTERN = Pattern + .compile("^-\\d+(?:\\.\\d+)*(?:[.-].+)?$"); + + @Override + public boolean isMatch(String sourceFolder, URL url) { + String jarName = getJarName(url); + if (!StringUtils.hasLength(jarName)) { + return false; + } + return isMatch(sourceFolder, jarName); + } + + private String getJarName(URL url) { + Matcher matcher = URL_MODULE_PATTERN.matcher(url.toString()); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private boolean isMatch(String sourceFolder, String jarName) { + sourceFolder = stripTrailingSlash(sourceFolder); + sourceFolder = stripCommonEnds(sourceFolder); + String[] folders = StringUtils.delimitedListToStringArray(sourceFolder, "/"); + for (int i = folders.length - 1; i >= 0; i--) { + if (isFolderMatch(folders[i], jarName)) { + return true; + } + } + return false; + } + + private boolean isFolderMatch(String folder, String jarName) { + if (!jarName.startsWith(folder)) { + return false; + } + String version = jarName.substring(folder.length()); + return version.isEmpty() || VERSION_PATTERN.matcher(version).matches(); + } + + private String stripTrailingSlash(String string) { + if (string.endsWith("/")) { + return string.substring(0, string.length() - 1); + } + return string; + } + + private String stripCommonEnds(String string) { + for (String ending : COMMON_ENDINGS) { + if (string.endsWith(ending)) { + return string.substring(0, string.length() - ending.length()); + } + } + return string; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java new file mode 100644 index 00000000000..91af3bba847 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.server; + +import java.io.IOException; +import java.io.ObjectInputStream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * A HTTP server that can be used to upload updated {@link ClassLoaderFiles} and trigger + * restarts. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartServer + */ +public class HttpRestartServer { + + private static final Log logger = LogFactory.getLog(HttpRestartServer.class); + + private final RestartServer server; + + /** + * Create a new {@link HttpRestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + */ + public HttpRestartServer(SourceFolderUrlFilter sourceFolderUrlFilter) { + Assert.notNull(sourceFolderUrlFilter, "SourceFolderUrlFilter must not be null"); + this.server = new RestartServer(sourceFolderUrlFilter); + } + + /** + * Create a new {@link HttpRestartServer} instance. + * @param restartServer the underlying restart server + */ + public HttpRestartServer(RestartServer restartServer) { + Assert.notNull(restartServer, "RestartServer must not be null"); + this.server = restartServer; + } + + /** + * Handle a server request. + * @param request the request + * @param response the response + * @throws IOException + */ + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + try { + Assert.state(request.getHeaders().getContentLength() > 0, "No content"); + ObjectInputStream objectInputStream = new ObjectInputStream(request.getBody()); + ClassLoaderFiles files = (ClassLoaderFiles) objectInputStream.readObject(); + objectInputStream.close(); + this.server.updateAndRestart(files); + response.setStatusCode(HttpStatus.OK); + } + catch (Exception ex) { + logger.warn("Unable to handler restart server HTTP request", ex); + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java new file mode 100644 index 00000000000..10f72fa715a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/HttpRestartServerHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.server; + +import java.io.IOException; + +import org.springframework.boot.developertools.remote.server.Handler; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * Adapts {@link HttpRestartServer} to a {@link Handler}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpRestartServerHandler implements Handler { + + private final HttpRestartServer server; + + /** + * Create a new {@link HttpRestartServerHandler} instance. + * @param server the server to adapt + */ + public HttpRestartServerHandler(HttpRestartServer server) { + Assert.notNull(server, "Server must not be null"); + this.server = server; + } + + @Override + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + this.server.handle(request, response); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java new file mode 100644 index 00000000000..219ada356b0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/RestartServer.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.server; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.LinkedHashSet; +import java.util.Map.Entry; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.Restarter; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles.SourceFolder; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Server used to {@link Restarter restart} the current application with updated + * {@link ClassLoaderFiles}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class RestartServer { + + private static final Log logger = LogFactory.getLog(RestartServer.class); + + private final SourceFolderUrlFilter sourceFolderUrlFilter; + + private final ClassLoader classLoader; + + /** + * Create a new {@link RestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + */ + public RestartServer(SourceFolderUrlFilter sourceFolderUrlFilter) { + this(sourceFolderUrlFilter, Thread.currentThread().getContextClassLoader()); + } + + /** + * Create a new {@link RestartServer} instance. + * @param sourceFolderUrlFilter the source filter used to link remote folder to the + * local classpath + * @param classLoader the application classloader + */ + public RestartServer(SourceFolderUrlFilter sourceFolderUrlFilter, + ClassLoader classLoader) { + Assert.notNull(sourceFolderUrlFilter, "SourceFolderUrlFilter must not be null"); + Assert.notNull(classLoader, "ClassLoader must not be null"); + this.sourceFolderUrlFilter = sourceFolderUrlFilter; + this.classLoader = classLoader; + } + + /** + * Update the current running application with the specified {@link ClassLoaderFiles} + * and trigger a reload. + * @param files updated class loader files + */ + public void updateAndRestart(ClassLoaderFiles files) { + Set urls = new LinkedHashSet(); + Set classLoaderUrls = getClassLoaderUrls(); + for (SourceFolder folder : files.getSourceFolders()) { + for (Entry entry : folder.getFilesEntrySet()) { + for (URL url : classLoaderUrls) { + if (updateFileSystem(url, entry.getKey(), entry.getValue())) { + urls.add(url); + } + } + } + urls.addAll(getMatchingUrls(classLoaderUrls, folder.getName())); + } + updateTimeStamp(urls); + restart(urls, files); + + } + + private boolean updateFileSystem(URL url, String name, ClassLoaderFile classLoaderFile) { + if (!isFolderUrl(url.toString())) { + return false; + } + try { + File folder = ResourceUtils.getFile(url); + File file = new File(folder, name); + if (file.exists() && file.canWrite()) { + if (classLoaderFile.getKind() == Kind.DELETED) { + return file.delete(); + } + FileCopyUtils.copy(classLoaderFile.getContents(), file); + return true; + } + } + catch (IOException ex) { + // Ignore + } + return false; + } + + private boolean isFolderUrl(String urlString) { + return urlString.startsWith("file:") && urlString.endsWith("/"); + } + + private Set getMatchingUrls(Set urls, String sourceFolder) { + Set matchingUrls = new LinkedHashSet(); + for (URL url : urls) { + if (this.sourceFolderUrlFilter.isMatch(sourceFolder, url)) { + if (logger.isDebugEnabled()) { + logger.debug("URL " + url + " matched against source folder " + + sourceFolder); + } + matchingUrls.add(url); + } + } + return matchingUrls; + } + + private Set getClassLoaderUrls() { + Set urls = new LinkedHashSet(); + ClassLoader classLoader = this.classLoader; + while (classLoader != null) { + if (classLoader instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) classLoader).getURLs()) { + urls.add(url); + } + } + classLoader = classLoader.getParent(); + } + return urls; + + } + + private void updateTimeStamp(Iterable urls) { + for (URL url : urls) { + updateTimeStamp(url); + } + } + + private void updateTimeStamp(URL url) { + try { + URL actualUrl = ResourceUtils.extractJarFileURL(url); + File file = ResourceUtils.getFile(actualUrl, "Jar URL"); + file.setLastModified(System.currentTimeMillis()); + } + catch (Exception ex) { + // Ignore + } + } + + /** + * Called to restart the application. + * @param urls the updated URLs + * @param files the updated files + */ + protected void restart(Set urls, ClassLoaderFiles files) { + Restarter restarter = Restarter.getInstance(); + restarter.addUrls(urls); + restarter.addClassLoaderFiles(files); + restarter.restart(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java new file mode 100644 index 00000000000..72c5257ee58 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/SourceFolderUrlFilter.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.restart.server; + +import java.net.URL; + +/** + * Filter URLs based on a source folder name. Used to match URLs from the running + * classpath against source folders on a remote system. + * + * @author Phillip Webb + * @since 1.3.0 + * @see DefaultSourceFolderUrlFilter + */ +public interface SourceFolderUrlFilter { + + /** + * Determine if the specified URL matches a source folder. + * @param sourceFolder the source folder + * @param url the URL to check + * @return {@code true} if the URL matches + */ + boolean isMatch(String sourceFolder, URL url); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java new file mode 100644 index 00000000000..9a2d583aa5a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/server/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Remote restart server + */ +package org.springframework.boot.developertools.restart.server; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/HttpTunnelConnection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/HttpTunnelConnection.java new file mode 100644 index 00000000000..67a325603d6 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/HttpTunnelConnection.java @@ -0,0 +1,216 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.client; + +import java.io.Closeable; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.channels.WritableByteChannel; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.tunnel.payload.HttpTunnelPayload; +import org.springframework.boot.developertools.tunnel.payload.HttpTunnelPayloadForwarder; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.util.Assert; + +/** + * {@link TunnelConnection} implementation that uses HTTP to transfer data. + * + * @author Phillip Webb + * @author Rob Winch + * @since 1.3.0 + * @see TunnelClient + * @see org.springframework.boot.developertools.tunnel.server.HttpTunnelServer + */ +public class HttpTunnelConnection implements TunnelConnection { + + private static Log logger = LogFactory.getLog(HttpTunnelConnection.class); + + private final URI uri; + + private final ClientHttpRequestFactory requestFactory; + + private final Executor executor; + + /** + * Create a new {@link HttpTunnelConnection} instance. + * @param url the URL to connect to + * @param requestFactory the HTTP request factory + */ + public HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory) { + this(url, requestFactory, null); + } + + /** + * Create a new {@link HttpTunnelConnection} instance. + * @param url the URL to connect to + * @param requestFactory the HTTP request factory + * @param executor the executor used to handle connections + */ + protected HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory, + Executor executor) { + Assert.hasLength(url, "URL must not be empty"); + Assert.notNull(requestFactory, "RequestFactory must not be null"); + try { + this.uri = new URL(url).toURI(); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException("Malformed URL '" + url + "'"); + } + catch (MalformedURLException ex) { + throw new IllegalArgumentException("Malformed URL '" + url + "'"); + } + this.requestFactory = requestFactory; + this.executor = (executor == null ? Executors + .newCachedThreadPool(new TunnelThreadFactory()) : executor); + } + + @Override + public TunnelChannel open(WritableByteChannel incomingChannel, Closeable closeable) + throws Exception { + logger.trace("Opening HTTP tunnel to " + this.uri); + return new TunnelChannel(incomingChannel, closeable); + } + + protected final ClientHttpRequest createRequest(boolean hasPayload) + throws IOException { + HttpMethod method = (hasPayload ? HttpMethod.POST : HttpMethod.GET); + return this.requestFactory.createRequest(this.uri, method); + } + + /** + * A {@link WritableByteChannel} used to transfer traffic. + */ + protected class TunnelChannel implements WritableByteChannel { + + private final HttpTunnelPayloadForwarder forwarder; + + private final Closeable closeable; + + private boolean open = true; + + private AtomicLong requestSeq = new AtomicLong(); + + public TunnelChannel(WritableByteChannel incomingChannel, Closeable closeable) { + this.forwarder = new HttpTunnelPayloadForwarder(incomingChannel); + this.closeable = closeable; + openNewConnection(null); + } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public void close() throws IOException { + if (this.open) { + this.open = false; + this.closeable.close(); + } + } + + @Override + public int write(ByteBuffer src) throws IOException { + int size = src.remaining(); + if (size > 0) { + openNewConnection(new HttpTunnelPayload( + this.requestSeq.incrementAndGet(), src)); + } + return size; + } + + private synchronized void openNewConnection(final HttpTunnelPayload payload) { + HttpTunnelConnection.this.executor.execute(new Runnable() { + + @Override + public void run() { + try { + sendAndReceive(payload); + } + catch (IOException ex) { + logger.trace("Unexpected connection error", ex); + closeQuitely(); + } + } + + private void closeQuitely() { + try { + close(); + } + catch (IOException ex) { + } + } + + }); + } + + private void sendAndReceive(HttpTunnelPayload payload) throws IOException { + ClientHttpRequest request = createRequest(payload != null); + if (payload != null) { + payload.logIncoming(); + payload.assignTo(request); + } + handleResponse(request.execute()); + } + + private void handleResponse(ClientHttpResponse response) throws IOException { + if (response.getStatusCode() == HttpStatus.GONE) { + close(); + return; + } + if (response.getStatusCode() == HttpStatus.OK) { + HttpTunnelPayload payload = HttpTunnelPayload.get(response); + if (payload != null) { + this.forwarder.forward(payload); + } + } + if (response.getStatusCode() != HttpStatus.TOO_MANY_REQUESTS) { + openNewConnection(null); + } + } + + } + + /** + * {@link ThreadFactory} used to create the tunnel thread. + */ + private static class TunnelThreadFactory implements ThreadFactory { + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "HTTP Tunnel Connection"); + thread.setDaemon(true); + return thread; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClient.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClient.java new file mode 100644 index 00000000000..85e5bb13c9a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClient.java @@ -0,0 +1,207 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.client; + +import java.io.Closeable; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.ByteBuffer; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.channels.WritableByteChannel; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.util.Assert; + +/** + * The client side component of a socket tunnel. Starts a {@link ServerSocket} of the + * specified port for local clients to connect to. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class TunnelClient implements SmartInitializingSingleton { + + private static final int BUFFER_SIZE = 1024 * 100; + + private static final Log logger = LogFactory.getLog(TunnelClient.class); + + private final int listenPort; + + private final TunnelConnection tunnelConnection; + + private TunnelClientListeners listeners = new TunnelClientListeners(); + + private ServerThread serverThread; + + public TunnelClient(int listenPort, TunnelConnection tunnelConnection) { + Assert.isTrue(listenPort > 0, "ListenPort must be positive"); + Assert.notNull(tunnelConnection, "TunnelConnection must not be null"); + this.listenPort = listenPort; + this.tunnelConnection = tunnelConnection; + } + + @Override + public void afterSingletonsInstantiated() { + if (this.serverThread == null) { + try { + start(); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + } + + /** + * Start the client and accept incoming connections on the port. + * @throws IOException + */ + public synchronized void start() throws IOException { + Assert.state(this.serverThread == null, "Server already started"); + ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); + serverSocketChannel.socket().bind(new InetSocketAddress(this.listenPort)); + logger.trace("Listening for TCP traffic to tunnel on port " + this.listenPort); + this.serverThread = new ServerThread(serverSocketChannel); + this.serverThread.start(); + } + + /** + * Stop the client, disconnecting any servers. + * @throws IOException + */ + public synchronized void stop() throws IOException { + if (this.serverThread != null) { + logger.trace("Closing tunnel client on port " + this.listenPort); + this.serverThread.close(); + try { + this.serverThread.join(2000); + } + catch (InterruptedException ex) { + } + this.serverThread = null; + } + } + + protected final ServerThread getServerThread() { + return this.serverThread; + } + + public void addListener(TunnelClientListener listener) { + this.listeners.addListener(listener); + } + + public void removeListener(TunnelClientListener listener) { + this.listeners.removeListener(listener); + } + + /** + * The main server thread. + */ + protected class ServerThread extends Thread { + + private final ServerSocketChannel serverSocketChannel; + + private boolean acceptConnections = true; + + public ServerThread(ServerSocketChannel serverSocketChannel) { + this.serverSocketChannel = serverSocketChannel; + setName("Tunnel Server"); + setDaemon(true); + } + + public void close() throws IOException { + this.serverSocketChannel.close(); + this.acceptConnections = false; + interrupt(); + } + + @Override + public void run() { + try { + while (this.acceptConnections) { + SocketChannel socket = this.serverSocketChannel.accept(); + try { + handleConnection(socket); + } + finally { + socket.close(); + } + } + } + catch (Exception ex) { + logger.trace("Unexpected exception from tunnel client", ex); + } + } + + private void handleConnection(SocketChannel socketChannel) throws Exception { + Closeable closeable = new SocketCloseable(socketChannel); + WritableByteChannel outputChannel = TunnelClient.this.tunnelConnection.open( + socketChannel, closeable); + TunnelClient.this.listeners.fireOpenEvent(socketChannel); + try { + logger.trace("Accepted connection to tunnel client from " + + socketChannel.socket().getRemoteSocketAddress()); + while (true) { + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + int amountRead = socketChannel.read(buffer); + if (amountRead == -1) { + outputChannel.close(); + return; + } + if (amountRead > 0) { + buffer.flip(); + outputChannel.write(buffer); + } + } + } + finally { + outputChannel.close(); + } + } + + protected void stopAcceptingConnections() { + this.acceptConnections = false; + } + } + + /** + * {@link Closeable} used to close a {@link SocketChannel} and fire an event. + */ + private class SocketCloseable implements Closeable { + + private final SocketChannel socketChannel; + + private boolean closed = false; + + public SocketCloseable(SocketChannel socketChannel) { + this.socketChannel = socketChannel; + } + + @Override + public void close() throws IOException { + if (!this.closed) { + this.socketChannel.close(); + TunnelClient.this.listeners.fireCloseEvent(this.socketChannel); + this.closed = true; + } + } + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListener.java new file mode 100644 index 00000000000..af7e7af634e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListener.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.client; + +import java.nio.channels.SocketChannel; + +/** + * Listener that can be used to receive {@link TunnelClient} events. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface TunnelClientListener { + + /** + * Called when a socket channel is opened. + * @param socket the socket channel + */ + void onOpen(SocketChannel socket); + + /** + * Called when a socket channel is closed. + * @param socket the socket channel + */ + void onClose(SocketChannel socket); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListeners.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListeners.java new file mode 100644 index 00000000000..dc5b33e3b40 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelClientListeners.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.client; + +import java.nio.channels.SocketChannel; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * A collection of {@link TunnelClientListener}. + * + * @author Phillip Webb + */ +class TunnelClientListeners { + + private final List listeners = new ArrayList(); + + public void addListener(TunnelClientListener listener) { + Assert.notNull(listener, "Listener must not be null"); + this.listeners.add(listener); + } + + public void removeListener(TunnelClientListener listener) { + Assert.notNull(listener, "Listener must not be null"); + this.listeners.remove(listener); + } + + public void fireOpenEvent(SocketChannel socket) { + for (TunnelClientListener listener : this.listeners) { + listener.onOpen(socket); + } + } + + public void fireCloseEvent(SocketChannel socket) { + for (TunnelClientListener listener : this.listeners) { + listener.onClose(socket); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelConnection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelConnection.java new file mode 100644 index 00000000000..f885b2a1ee8 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/TunnelConnection.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.client; + +import java.io.Closeable; +import java.nio.channels.WritableByteChannel; + +/** + * Interface used to manage socket tunnel connections. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface TunnelConnection { + + /** + * Open the tunnel connection. + * @param incomingChannel A {@link WritableByteChannel} that should be used to write + * any incoming data received from the remote server. + * @param closeable + * @return A {@link WritableByteChannel} that should be used to send any outgoing data + * destined for the remote server + * @throws Exception + */ + WritableByteChannel open(WritableByteChannel incomingChannel, Closeable closeable) + throws Exception; + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/package-info.java new file mode 100644 index 00000000000..109e9d16f84 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/client/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Client side TCP tunnel support. + */ +package org.springframework.boot.developertools.tunnel.client; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/package-info.java new file mode 100644 index 00000000000..ee9ad911d2a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Provides support for tunneling TCP traffic over HTTP. Tunneling is primarily designed + * for the Java Debug Wire Protocol (JDWP) and as such only expects a single connection + * and isn't particularly worried about resource usage. + */ +package org.springframework.boot.developertools.tunnel; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayload.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayload.java new file mode 100644 index 00000000000..72dfe63422f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayload.java @@ -0,0 +1,185 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.payload; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Encapsulates a payload data sent via a HTTP tunnel. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpTunnelPayload { + + private static final String SEQ_HEADER = "x-seq"; + + private static final int BUFFER_SIZE = 1024 * 100; + + final protected static char[] HEX_CHARS = "0123456789ABCDEF".toCharArray(); + + private static final Log logger = LogFactory.getLog(HttpTunnelPayload.class); + + private final long sequence; + + private final ByteBuffer data; + + /** + * Create a new {@link HttpTunnelPayload} instance. + * @param sequence the sequence number of the payload + * @param data the payload data + */ + public HttpTunnelPayload(long sequence, ByteBuffer data) { + Assert.isTrue(sequence > 0, "Sequence must be positive"); + Assert.notNull(data, "Data must not be null"); + this.sequence = sequence; + this.data = data; + } + + /** + * Return the sequence number of the payload. + * @return the sequence + */ + public long getSequence() { + return this.sequence; + } + + /** + * Assign this payload to the given {@link HttpOutputMessage}. + * @param message the message to assign this payload to + * @throws IOException + */ + public void assignTo(HttpOutputMessage message) throws IOException { + Assert.notNull(message, "Message must not be null"); + HttpHeaders headers = message.getHeaders(); + headers.setContentLength(this.data.remaining()); + headers.add(SEQ_HEADER, Long.toString(getSequence())); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + WritableByteChannel body = Channels.newChannel(message.getBody()); + while (this.data.hasRemaining()) { + body.write(this.data); + } + body.close(); + } + + /** + * Write the content of this payload to the given target channel. + * @param channel the channel to write to + * @throws IOException + */ + public void writeTo(WritableByteChannel channel) throws IOException { + Assert.notNull(channel, "Channel must not be null"); + while (this.data.hasRemaining()) { + channel.write(this.data); + } + } + + /** + * Return the {@link HttpTunnelPayload} for the given message or {@code null} if there + * is no payload. + * @param message the HTTP message + * @return the payload or {@code null} + * @throws IOException + */ + public static HttpTunnelPayload get(HttpInputMessage message) throws IOException { + long length = message.getHeaders().getContentLength(); + if (length <= 0) { + return null; + } + String seqHeader = message.getHeaders().getFirst(SEQ_HEADER); + Assert.state(StringUtils.hasLength(seqHeader), "Missing sequence header"); + ReadableByteChannel body = Channels.newChannel(message.getBody()); + ByteBuffer payload = ByteBuffer.allocate((int) length); + while (payload.hasRemaining()) { + body.read(payload); + } + body.close(); + payload.flip(); + return new HttpTunnelPayload(Long.valueOf(seqHeader), payload); + } + + /** + * Return the payload data for the given source {@link ReadableByteChannel} or null if + * the channel timed out whilst reading. + * @param channel the source channel + * @return payload data or {@code null} + * @throws IOException + */ + public static ByteBuffer getPayloadData(ReadableByteChannel channel) + throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + try { + int amountRead = channel.read(buffer); + Assert.state(amountRead != -1, "Target server connection closed"); + buffer.flip(); + return buffer; + } + catch (InterruptedIOException ex) { + return null; + } + } + + /** + * Log incoming payload information at trace level to aid diagnostics. + */ + public void logIncoming() { + log("< "); + } + + /** + * Log incoming payload information at trace level to aid diagnostics. + */ + public void logOutgoing() { + log("> "); + } + + private void log(String prefix) { + if (logger.isTraceEnabled()) { + logger.trace(prefix + toHexString()); + } + } + + /** + * Return the payload as a hexadecimal string. + * @return the payload as a hex string + */ + public String toHexString() { + byte[] bytes = this.data.array(); + char[] hex = new char[this.data.remaining() * 2]; + for (int i = this.data.position(); i < this.data.remaining(); i++) { + int b = bytes[i] & 0xFF; + hex[i * 2] = HEX_CHARS[b >>> 4]; + hex[i * 2 + 1] = HEX_CHARS[b & 0x0F]; + } + return new String(hex); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayloadForwarder.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayloadForwarder.java new file mode 100644 index 00000000000..328b1954c5a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/HttpTunnelPayloadForwarder.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.payload; + +import java.io.IOException; +import java.nio.channels.WritableByteChannel; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * Utility class that forwards {@link HttpTunnelPayload} instances to a destination + * channel, respecting sequence order. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpTunnelPayloadForwarder { + + private static final int MAXIMUM_QUEUE_SIZE = 100; + + private final WritableByteChannel targetChannel; + + private long lastRequestSeq = 0; + + private final Map queue = new HashMap(); + + /** + * Create a new {@link HttpTunnelPayloadForwarder} instance. + * @param targetChannel the target channel + */ + public HttpTunnelPayloadForwarder(WritableByteChannel targetChannel) { + Assert.notNull(targetChannel, "TargetChannel must not be null"); + this.targetChannel = targetChannel; + } + + public synchronized void forward(HttpTunnelPayload payload) throws IOException { + long seq = payload.getSequence(); + if (this.lastRequestSeq != seq - 1) { + Assert.state(this.queue.size() < MAXIMUM_QUEUE_SIZE, + "Too many messages queued"); + this.queue.put(seq, payload); + return; + } + payload.logOutgoing(); + payload.writeTo(this.targetChannel); + this.lastRequestSeq = seq; + HttpTunnelPayload queuedItem = this.queue.get(seq + 1); + if (queuedItem != null) { + forward(queuedItem); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/package-info.java new file mode 100644 index 00000000000..fdf6429f18f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/payload/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Classes to deal with payloads sent over a HTTP tunnel. + */ +package org.springframework.boot.developertools.tunnel.payload; + diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServer.java new file mode 100644 index 00000000000..976109f45f0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServer.java @@ -0,0 +1,486 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import java.io.IOException; +import java.net.ConnectException; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.tunnel.payload.HttpTunnelPayload; +import org.springframework.boot.developertools.tunnel.payload.HttpTunnelPayloadForwarder; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpAsyncRequestControl; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * A server that can be used to tunnel TCP traffic over HTTP. Similar in design to the Bidirectional-streams Over Synchronous + * HTTP (BOSH) XMPP extension protocol, the server uses long polling with HTTP + * requests held open until a response is available. A typical traffic pattern would be as + * follows: + * + *

+ * [ CLIENT ]                      [ SERVER ]
+ *     | (a) Initial empty request     |
+ *     |------------------------------}|
+ *     | (b) Data I                    |
+ *  --}|------------------------------}|---}
+ *     | Response I (a)                |
+ *  {--|<------------------------------|{---
+ *     |                               |
+ *     | (c) Data II                   |
+ *  --}|------------------------------}|---}
+ *     | Response II (b)               |
+ *  {--|{------------------------------|{---
+ *     .                               .
+ *     .                               .
+ * 
+ * + * Each incoming request is held open to be used to carry the next available response. The + * server will hold at most two connections open at any given time. + *

+ * Requests should be made using HTTP GET or POST (depending if there is a payload), with + * any payload contained in the body. The following response codes can be returned from + * the server: + *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
StatusMeaning
200 (OK)Data payload response.
204 (No Content)The long poll has timed out and the client should start a new request.
429 (Too many requests)There are already enough connections open, this one can be dropped.
410 (Gone)The target server has disconnected.
+ *

+ * Requests and responses that contain payloads include a {@code x-seq} header that + * contains a running sequence number (used to ensure data is applied in the correct + * order). The first request containing a payload should have a {@code x-seq} value of + * {@code 1}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see org.springframework.boot.developertools.tunnel.client.HttpTunnelConnection + */ +public class HttpTunnelServer { + + private static final int SECONDS = 1000; + + private static final int DEFAULT_LONG_POLL_TIMEOUT = 10 * SECONDS; + + private static final long DEFAULT_DISCONNECT_TIMEOUT = 30 * SECONDS; + + private static final MediaType DISCONNECT_MEDIA_TYPE = new MediaType("application", + "x-disconnect"); + + private static final Log logger = LogFactory.getLog(HttpTunnelServer.class); + + private final TargetServerConnection serverConnection; + + private int longPollTimeout = DEFAULT_LONG_POLL_TIMEOUT; + + private long disconnectTimeout = DEFAULT_DISCONNECT_TIMEOUT; + + private volatile ServerThread serverThread; + + /** + * Creates a new {@link HttpTunnelServer} instance. + * @param serverConnection the connection to the target server + */ + public HttpTunnelServer(TargetServerConnection serverConnection) { + Assert.notNull(serverConnection, "ServerConnection must not be null"); + this.serverConnection = serverConnection; + } + + /** + * Handle an incoming HTTP connection. + * @param request the HTTP request + * @param response the HTTP response + * @throws IOException + */ + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + handle(new HttpConnection(request, response)); + } + + /** + * Handle an incoming HTTP connection. + * @param httpConnection the HTTP connection + * @throws IOException + */ + protected void handle(HttpConnection httpConnection) throws IOException { + try { + getServerThread().handleIncomingHttp(httpConnection); + httpConnection.waitForResponse(); + } + catch (ConnectException ex) { + httpConnection.respond(HttpStatus.GONE); + } + } + + /** + * Returns the active server thread, creating and starting it if necessary. + * @return the {@code ServerThread} (never {@code null}) + * @throws IOException + */ + protected ServerThread getServerThread() throws IOException { + synchronized (this) { + if (this.serverThread == null) { + ByteChannel channel = this.serverConnection.open(this.longPollTimeout); + this.serverThread = new ServerThread(channel); + this.serverThread.start(); + } + return this.serverThread; + } + } + + /** + * Called when the server thread exits. + */ + void clearServerThread() { + synchronized (this) { + this.serverThread = null; + } + } + + /** + * Set the long poll timeout for the server. + * @param longPollTimeout the long poll timeout in milliseconds + */ + public void setLongPollTimeout(int longPollTimeout) { + Assert.isTrue(longPollTimeout > 0, "LongPollTimeout must be a positive value"); + this.longPollTimeout = longPollTimeout; + } + + /** + * Set the maximum amount of time to wait for a client before closing the connection. + * @param disconnectTimeout the disconnect timeout in milliseconds + */ + public void setDisconnectTimeout(long disconnectTimeout) { + Assert.isTrue(disconnectTimeout > 0, "DisconnectTimeout must be a positive value"); + this.disconnectTimeout = disconnectTimeout; + } + + /** + * The main server thread used to transfer tunnel traffic. + */ + protected class ServerThread extends Thread { + + private final ByteChannel targetServer; + + private final Deque httpConnections; + + private final HttpTunnelPayloadForwarder payloadForwarder; + + private boolean closed; + + private AtomicLong responseSeq = new AtomicLong(); + + private long lastHttpRequestTime; + + public ServerThread(ByteChannel targetServer) { + Assert.notNull(targetServer, "TargetServer must not be null"); + this.targetServer = targetServer; + this.httpConnections = new ArrayDeque(2); + this.payloadForwarder = new HttpTunnelPayloadForwarder(targetServer); + } + + @Override + public void run() { + try { + try { + readAndForwardTargetServerData(); + } + catch (Exception ex) { + logger.trace("Unexpected exception from tunnel server", ex); + } + } + finally { + this.closed = true; + closeHttpConnections(); + closeTargetServer(); + HttpTunnelServer.this.clearServerThread(); + } + } + + private void readAndForwardTargetServerData() throws IOException { + while (this.targetServer.isOpen()) { + closeStaleHttpConnections(); + ByteBuffer data = HttpTunnelPayload.getPayloadData(this.targetServer); + synchronized (this.httpConnections) { + if (data != null) { + HttpTunnelPayload payload = new HttpTunnelPayload( + this.responseSeq.incrementAndGet(), data); + payload.logIncoming(); + HttpConnection connection = getOrWaitForHttpConnection(); + connection.respond(payload); + } + } + } + } + + private HttpConnection getOrWaitForHttpConnection() { + synchronized (this.httpConnections) { + HttpConnection httpConnection = this.httpConnections.pollFirst(); + while (httpConnection == null) { + try { + this.httpConnections.wait(HttpTunnelServer.this.longPollTimeout); + } + catch (InterruptedException ex) { + closeHttpConnections(); + } + httpConnection = this.httpConnections.pollFirst(); + } + return httpConnection; + } + } + + private void closeStaleHttpConnections() throws IOException { + checkNotDisconnected(); + synchronized (this.httpConnections) { + Iterator iterator = this.httpConnections.iterator(); + while (iterator.hasNext()) { + HttpConnection httpConnection = iterator.next(); + if (httpConnection.isOlderThan(HttpTunnelServer.this.longPollTimeout)) { + httpConnection.respond(HttpStatus.NO_CONTENT); + iterator.remove(); + } + } + } + } + + private void checkNotDisconnected() { + long timeout = HttpTunnelServer.this.disconnectTimeout; + long duration = System.currentTimeMillis() - this.lastHttpRequestTime; + Assert.state(duration < timeout, "Disconnect timeout"); + } + + private void closeHttpConnections() { + synchronized (this.httpConnections) { + while (!this.httpConnections.isEmpty()) { + try { + this.httpConnections.removeFirst().respond(HttpStatus.GONE); + } + catch (Exception ex) { + logger.trace("Unable to close remote HTTP connection"); + } + } + } + } + + private void closeTargetServer() { + try { + this.targetServer.close(); + } + catch (IOException ex) { + logger.trace("Unable to target server connection"); + } + } + + /** + * Handle an incoming {@link HttpConnection}. + * @param httpConnection the connection to handle. + * @throws IOException + */ + public void handleIncomingHttp(HttpConnection httpConnection) throws IOException { + if (this.closed) { + httpConnection.respond(HttpStatus.GONE); + } + synchronized (this.httpConnections) { + while (this.httpConnections.size() > 1) { + this.httpConnections.removeFirst().respond( + HttpStatus.TOO_MANY_REQUESTS); + } + this.lastHttpRequestTime = System.currentTimeMillis(); + this.httpConnections.addLast(httpConnection); + this.httpConnections.notify(); + } + forwardToTargetServer(httpConnection); + } + + private void forwardToTargetServer(HttpConnection httpConnection) + throws IOException { + if (httpConnection.isDisconnectRequest()) { + this.targetServer.close(); + interrupt(); + } + ServerHttpRequest request = httpConnection.getRequest(); + HttpTunnelPayload payload = HttpTunnelPayload.get(request); + if (payload != null) { + this.payloadForwarder.forward(payload); + } + } + + } + + /** + * Encapsulates a HTTP request/response pair. + */ + protected static class HttpConnection { + + private final long createTime; + + private final ServerHttpRequest request; + + private final ServerHttpResponse response; + + private ServerHttpAsyncRequestControl async; + + private volatile boolean complete = false; + + public HttpConnection(ServerHttpRequest request, ServerHttpResponse response) { + this.createTime = System.currentTimeMillis(); + this.request = request; + this.response = response; + this.async = startAsync(); + } + + /** + * Start asynchronous support or if unavailble return {@code null} to cause + * {@link #waitForResponse()} to block. + * @return the async request control + */ + protected ServerHttpAsyncRequestControl startAsync() { + try { + // Try to use async to save blocking + ServerHttpAsyncRequestControl async = this.request + .getAsyncRequestControl(this.response); + async.start(); + return async; + } + catch (Exception ex) { + return null; + } + } + + /** + * Return the underlying request. + * @return the request + */ + public final ServerHttpRequest getRequest() { + return this.request; + } + + /** + * Return the underlying response. + * @return the response + */ + protected final ServerHttpResponse getResponse() { + return this.response; + } + + /** + * Determine if a connection is older than the specified time. + * @param time the time to check + * @return {@code true} if the request is older than the time + */ + public boolean isOlderThan(int time) { + long runningTime = System.currentTimeMillis() - this.createTime; + return (runningTime > time); + } + + /** + * Cause the request to block or use asynchronous methods to wait until a response + * is available. + */ + public void waitForResponse() { + if (this.async == null) { + while (!this.complete) { + try { + synchronized (this) { + wait(1000); + } + } + catch (InterruptedException ex) { + } + } + } + } + + /** + * Detect if the request is actually a signal to disconnect. + * @return if the request is a signal to disconnect + */ + public boolean isDisconnectRequest() { + return DISCONNECT_MEDIA_TYPE.equals(this.request.getHeaders() + .getContentType()); + } + + /** + * Send a HTTP status response. + * @param status the status to send + * @throws IOException + */ + public void respond(HttpStatus status) throws IOException { + Assert.notNull(status, "Status must not be null"); + this.response.setStatusCode(status); + complete(); + } + + /** + * Send a payload response. + * @param payload the payload to send + * @throws IOException + */ + public void respond(HttpTunnelPayload payload) throws IOException { + Assert.notNull(payload, "Payload must not be null"); + this.response.setStatusCode(HttpStatus.OK); + payload.assignTo(this.response); + complete(); + } + + /** + * Called when a request is complete. + */ + protected void complete() { + if (this.async != null) { + this.async.complete(); + } + else { + synchronized (this) { + this.complete = true; + notifyAll(); + } + } + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServerHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServerHandler.java new file mode 100644 index 00000000000..2ad4c976e6f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/HttpTunnelServerHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import java.io.IOException; + +import org.springframework.boot.developertools.remote.server.Handler; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; + +/** + * Adapts a {@link HttpTunnelServer} to a {@link Handler}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class HttpTunnelServerHandler implements Handler { + + private HttpTunnelServer server; + + /** + * Create a new {@link HttpTunnelServerHandler} instance. + * @param server the server to adapt + */ + public HttpTunnelServerHandler(HttpTunnelServer server) { + Assert.notNull(server, "Server must not be null"); + this.server = server; + } + + @Override + public void handle(ServerHttpRequest request, ServerHttpResponse response) + throws IOException { + this.server.handle(request, response); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/PortProvider.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/PortProvider.java new file mode 100644 index 00000000000..adb75185021 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/PortProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +/** + * Strategy interface to provide access to a port (which may change if an existing + * connection is closed). + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface PortProvider { + + /** + * Return the port number + * @return the port number + */ + int getPort(); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/RemoteDebugPortProvider.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/RemoteDebugPortProvider.java new file mode 100644 index 00000000000..1dae7635c64 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/RemoteDebugPortProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.lang.UsesUnsafeJava; +import org.springframework.util.Assert; + +/** + * {@link PortProvider} that provides the port being used by the Java remote debugging. + * + * @author Phillip Webb + */ +public class RemoteDebugPortProvider implements PortProvider { + + private static final String JDWP_ADDRESS_PROPERTY = "sun.jdwp.listenerAddress"; + + private static final Log logger = LogFactory.getLog(RemoteDebugPortProvider.class); + + @Override + public int getPort() { + Assert.state(isRemoteDebugRunning(), "Remote debug is not running"); + return getRemoteDebugPort(); + } + + public static boolean isRemoteDebugRunning() { + return getRemoteDebugPort() != -1; + } + + @UsesUnsafeJava + @SuppressWarnings("restriction") + private static int getRemoteDebugPort() { + String property = sun.misc.VMSupport.getAgentProperties().getProperty( + JDWP_ADDRESS_PROPERTY); + try { + if (property != null && property.contains(":")) { + return Integer.valueOf(property.split(":")[1]); + } + } + catch (Exception ex) { + logger.trace("Unable to get JDWP port from property value '" + property + "'"); + } + return -1; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/SocketTargetServerConnection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/SocketTargetServerConnection.java new file mode 100644 index 00000000000..ddc070257e0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/SocketTargetServerConnection.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.SocketChannel; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; + +/** + * Socket based {@link TargetServerConnection}. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class SocketTargetServerConnection implements TargetServerConnection { + + private static final Log logger = LogFactory + .getLog(SocketTargetServerConnection.class); + + private final PortProvider portProvider; + + /** + * Create a new {@link SocketTargetServerConnection}. + * @param portProvider the port provider + */ + public SocketTargetServerConnection(PortProvider portProvider) { + Assert.notNull(portProvider, "PortProvider must not be null"); + this.portProvider = portProvider; + } + + @Override + public ByteChannel open(int socketTimeout) throws IOException { + SocketAddress address = new InetSocketAddress(this.portProvider.getPort()); + logger.trace("Opening tunnel connection to target server on " + address); + SocketChannel channel = SocketChannel.open(address); + channel.socket().setSoTimeout(socketTimeout); + return new TimeoutAwareChannel(channel); + } + + /** + * Wrapper to expose the {@link SocketChannel} in such a way that + * {@code SocketTimeoutExceptions} are still thrown from read methods. + */ + private static class TimeoutAwareChannel implements ByteChannel { + + private final SocketChannel socketChannel; + + private final ReadableByteChannel readChannel; + + public TimeoutAwareChannel(SocketChannel socketChannel) throws IOException { + this.socketChannel = socketChannel; + this.readChannel = Channels.newChannel(socketChannel.socket() + .getInputStream()); + } + + @Override + public int read(ByteBuffer dst) throws IOException { + return this.readChannel.read(dst); + } + + @Override + public int write(ByteBuffer src) throws IOException { + return this.socketChannel.write(src); + } + + @Override + public boolean isOpen() { + return this.socketChannel.isOpen(); + } + + @Override + public void close() throws IOException { + this.socketChannel.close(); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/StaticPortProvider.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/StaticPortProvider.java new file mode 100644 index 00000000000..34c129f6a1c --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/StaticPortProvider.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import org.springframework.util.Assert; + +/** + * {@link PortProvider} for a static port that won't change. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class StaticPortProvider implements PortProvider { + + private final int port; + + public StaticPortProvider(int port) { + Assert.isTrue(port > 0, "Port must be positive"); + this.port = port; + } + + @Override + public int getPort() { + return this.port; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/TargetServerConnection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/TargetServerConnection.java new file mode 100644 index 00000000000..26c5d2565fd --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/TargetServerConnection.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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 org.springframework.boot.developertools.tunnel.server; + +import java.io.IOException; +import java.nio.channels.ByteChannel; + +/** + * Manages the connection to the ultimate tunnel target server. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public interface TargetServerConnection { + + /** + * Open a connection to the target server with the specified timeout. + * @param timeout the read timeout + * @return a {@link ByteChannel} providing read/write access to the server + * @throws IOException + */ + ByteChannel open(int timeout) throws IOException; + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/package-info.java new file mode 100644 index 00000000000..85f70719a92 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/tunnel/server/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or 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 + * + * 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. + */ + +/** + * Server side TCP tunnel support. + */ +package org.springframework.boot.developertools.tunnel.server; + diff --git a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..69e53238a2f --- /dev/null +++ b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories @@ -0,0 +1,12 @@ +# Application Initializers +org.springframework.context.ApplicationContextInitializer=\ +org.springframework.boot.developertools.restart.RestartScopeInitializer + +# Application Listeners +org.springframework.context.ApplicationListener=\ +org.springframework.boot.developertools.restart.RestartApplicationListener + +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.boot.developertools.autoconfigure.LocalDeveloperToolsAutoConfiguration,\ +org.springframework.boot.developertools.autoconfigure.RemoteDeveloperToolsAutoConfiguration diff --git a/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js b/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js new file mode 100644 index 00000000000..edb280265cb --- /dev/null +++ b/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js @@ -0,0 +1,1055 @@ +(function() { +var __customevents = {}, __protocol = {}, __connector = {}, __timer = {}, __options = {}, __reloader = {}, __livereload = {}, __less = {}, __startup = {}; + +// customevents +var CustomEvents; +CustomEvents = { + bind: function(element, eventName, handler) { + if (element.addEventListener) { + return element.addEventListener(eventName, handler, false); + } else if (element.attachEvent) { + element[eventName] = 1; + return element.attachEvent('onpropertychange', function(event) { + if (event.propertyName === eventName) { + return handler(); + } + }); + } else { + throw new Error("Attempt to attach custom event " + eventName + " to something which isn't a DOMElement"); + } + }, + fire: function(element, eventName) { + var event; + if (element.addEventListener) { + event = document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + return document.dispatchEvent(event); + } else if (element.attachEvent) { + if (element[eventName]) { + return element[eventName]++; + } + } else { + throw new Error("Attempt to fire custom event " + eventName + " on something which isn't a DOMElement"); + } + } +}; +__customevents.bind = CustomEvents.bind; +__customevents.fire = CustomEvents.fire; + +// protocol +var PROTOCOL_6, PROTOCOL_7, Parser, ProtocolError; +var __indexOf = Array.prototype.indexOf || function(item) { + for (var i = 0, l = this.length; i < l; i++) { + if (this[i] === item) return i; + } + return -1; +}; +__protocol.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official-6'; +__protocol.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official-7'; +__protocol.ProtocolError = ProtocolError = (function() { + function ProtocolError(reason, data) { + this.message = "LiveReload protocol error (" + reason + ") after receiving data: \"" + data + "\"."; + } + return ProtocolError; +})(); +__protocol.Parser = Parser = (function() { + function Parser(handlers) { + this.handlers = handlers; + this.reset(); + } + Parser.prototype.reset = function() { + return this.protocol = null; + }; + Parser.prototype.process = function(data) { + var command, message, options, _ref; + try { + if (!(this.protocol != null)) { + if (data.match(/^!!ver:([\d.]+)$/)) { + this.protocol = 6; + } else if (message = this._parseMessage(data, ['hello'])) { + if (!message.protocols.length) { + throw new ProtocolError("no protocols specified in handshake message"); + } else if (__indexOf.call(message.protocols, PROTOCOL_7) >= 0) { + this.protocol = 7; + } else if (__indexOf.call(message.protocols, PROTOCOL_6) >= 0) { + this.protocol = 6; + } else { + throw new ProtocolError("no supported protocols found"); + } + } + return this.handlers.connected(this.protocol); + } else if (this.protocol === 6) { + message = JSON.parse(data); + if (!message.length) { + throw new ProtocolError("protocol 6 messages must be arrays"); + } + command = message[0], options = message[1]; + if (command !== 'refresh') { + throw new ProtocolError("unknown protocol 6 command"); + } + return this.handlers.message({ + command: 'reload', + path: options.path, + liveCSS: (_ref = options.apply_css_live) != null ? _ref : true + }); + } else { + message = this._parseMessage(data, ['reload', 'alert']); + return this.handlers.message(message); + } + } catch (e) { + if (e instanceof ProtocolError) { + return this.handlers.error(e); + } else { + throw e; + } + } + }; + Parser.prototype._parseMessage = function(data, validCommands) { + var message, _ref; + try { + message = JSON.parse(data); + } catch (e) { + throw new ProtocolError('unparsable JSON', data); + } + if (!message.command) { + throw new ProtocolError('missing "command" key', data); + } + if (_ref = message.command, __indexOf.call(validCommands, _ref) < 0) { + throw new ProtocolError("invalid command '" + message.command + "', only valid commands are: " + (validCommands.join(', ')) + ")", data); + } + return message; + }; + return Parser; +})(); + +// connector +// Generated by CoffeeScript 1.3.3 +var Connector, PROTOCOL_6, PROTOCOL_7, Parser, Version, _ref; + +_ref = __protocol, Parser = _ref.Parser, PROTOCOL_6 = _ref.PROTOCOL_6, PROTOCOL_7 = _ref.PROTOCOL_7; + +Version = '2.0.8'; + +__connector.Connector = Connector = (function() { + + function Connector(options, WebSocket, Timer, handlers) { + var _this = this; + this.options = options; + this.WebSocket = WebSocket; + this.Timer = Timer; + this.handlers = handlers; + this._uri = "ws://" + this.options.host + ":" + this.options.port + "/livereload"; + this._nextDelay = this.options.mindelay; + this._connectionDesired = false; + this.protocol = 0; + this.protocolParser = new Parser({ + connected: function(protocol) { + _this.protocol = protocol; + _this._handshakeTimeout.stop(); + _this._nextDelay = _this.options.mindelay; + _this._disconnectionReason = 'broken'; + return _this.handlers.connected(protocol); + }, + error: function(e) { + _this.handlers.error(e); + return _this._closeOnError(); + }, + message: function(message) { + return _this.handlers.message(message); + } + }); + this._handshakeTimeout = new Timer(function() { + if (!_this._isSocketConnected()) { + return; + } + _this._disconnectionReason = 'handshake-timeout'; + return _this.socket.close(); + }); + this._reconnectTimer = new Timer(function() { + if (!_this._connectionDesired) { + return; + } + return _this.connect(); + }); + this.connect(); + } + + Connector.prototype._isSocketConnected = function() { + return this.socket && this.socket.readyState === this.WebSocket.OPEN; + }; + + Connector.prototype.connect = function() { + var _this = this; + this._connectionDesired = true; + if (this._isSocketConnected()) { + return; + } + this._reconnectTimer.stop(); + this._disconnectionReason = 'cannot-connect'; + this.protocolParser.reset(); + this.handlers.connecting(); + this.socket = new this.WebSocket(this._uri); + this.socket.onopen = function(e) { + return _this._onopen(e); + }; + this.socket.onclose = function(e) { + return _this._onclose(e); + }; + this.socket.onmessage = function(e) { + return _this._onmessage(e); + }; + return this.socket.onerror = function(e) { + return _this._onerror(e); + }; + }; + + Connector.prototype.disconnect = function() { + this._connectionDesired = false; + this._reconnectTimer.stop(); + if (!this._isSocketConnected()) { + return; + } + this._disconnectionReason = 'manual'; + return this.socket.close(); + }; + + Connector.prototype._scheduleReconnection = function() { + if (!this._connectionDesired) { + return; + } + if (!this._reconnectTimer.running) { + this._reconnectTimer.start(this._nextDelay); + return this._nextDelay = Math.min(this.options.maxdelay, this._nextDelay * 2); + } + }; + + Connector.prototype.sendCommand = function(command) { + if (this.protocol == null) { + return; + } + return this._sendCommand(command); + }; + + Connector.prototype._sendCommand = function(command) { + return this.socket.send(JSON.stringify(command)); + }; + + Connector.prototype._closeOnError = function() { + this._handshakeTimeout.stop(); + this._disconnectionReason = 'error'; + return this.socket.close(); + }; + + Connector.prototype._onopen = function(e) { + var hello; + this.handlers.socketConnected(); + this._disconnectionReason = 'handshake-failed'; + hello = { + command: 'hello', + protocols: [PROTOCOL_6, PROTOCOL_7] + }; + hello.ver = Version; + if (this.options.ext) { + hello.ext = this.options.ext; + } + if (this.options.extver) { + hello.extver = this.options.extver; + } + if (this.options.snipver) { + hello.snipver = this.options.snipver; + } + this._sendCommand(hello); + return this._handshakeTimeout.start(this.options.handshake_timeout); + }; + + Connector.prototype._onclose = function(e) { + this.protocol = 0; + this.handlers.disconnected(this._disconnectionReason, this._nextDelay); + return this._scheduleReconnection(); + }; + + Connector.prototype._onerror = function(e) {}; + + Connector.prototype._onmessage = function(e) { + return this.protocolParser.process(e.data); + }; + + return Connector; + +})(); + +// timer +var Timer; +var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; +__timer.Timer = Timer = (function() { + function Timer(func) { + this.func = func; + this.running = false; + this.id = null; + this._handler = __bind(function() { + this.running = false; + this.id = null; + return this.func(); + }, this); + } + Timer.prototype.start = function(timeout) { + if (this.running) { + clearTimeout(this.id); + } + this.id = setTimeout(this._handler, timeout); + return this.running = true; + }; + Timer.prototype.stop = function() { + if (this.running) { + clearTimeout(this.id); + this.running = false; + return this.id = null; + } + }; + return Timer; +})(); +Timer.start = function(timeout, func) { + return setTimeout(func, timeout); +}; + +// options +var Options; +__options.Options = Options = (function() { + function Options() { + this.host = null; + this.port = 35729; + this.snipver = null; + this.ext = null; + this.extver = null; + this.mindelay = 1000; + this.maxdelay = 60000; + this.handshake_timeout = 5000; + } + Options.prototype.set = function(name, value) { + switch (typeof this[name]) { + case 'undefined': + break; + case 'number': + return this[name] = +value; + default: + return this[name] = value; + } + }; + return Options; +})(); +Options.extract = function(document) { + var element, keyAndValue, m, mm, options, pair, src, _i, _j, _len, _len2, _ref, _ref2; + _ref = document.getElementsByTagName('script'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + element = _ref[_i]; + if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) { + options = new Options(); + if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) { + options.host = mm[1]; + if (mm[2]) { + options.port = parseInt(mm[2], 10); + } + } + if (m[2]) { + _ref2 = m[2].split('&'); + for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) { + pair = _ref2[_j]; + if ((keyAndValue = pair.split('=')).length > 1) { + options.set(keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')); + } + } + } + return options; + } + } + return null; +}; + +// reloader +// Generated by CoffeeScript 1.3.1 +(function() { + var IMAGE_STYLES, Reloader, numberOfMatchingSegments, pathFromUrl, pathsMatch, pickBestMatch, splitUrl; + + splitUrl = function(url) { + var hash, index, params; + if ((index = url.indexOf('#')) >= 0) { + hash = url.slice(index); + url = url.slice(0, index); + } else { + hash = ''; + } + if ((index = url.indexOf('?')) >= 0) { + params = url.slice(index); + url = url.slice(0, index); + } else { + params = ''; + } + return { + url: url, + params: params, + hash: hash + }; + }; + + pathFromUrl = function(url) { + var path; + url = splitUrl(url).url; + if (url.indexOf('file://') === 0) { + path = url.replace(/^file:\/\/(localhost)?/, ''); + } else { + path = url.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//, '/'); + } + return decodeURIComponent(path); + }; + + pickBestMatch = function(path, objects, pathFunc) { + var bestMatch, object, score, _i, _len; + bestMatch = { + score: 0 + }; + for (_i = 0, _len = objects.length; _i < _len; _i++) { + object = objects[_i]; + score = numberOfMatchingSegments(path, pathFunc(object)); + if (score > bestMatch.score) { + bestMatch = { + object: object, + score: score + }; + } + } + if (bestMatch.score > 0) { + return bestMatch; + } else { + return null; + } + }; + + numberOfMatchingSegments = function(path1, path2) { + var comps1, comps2, eqCount, len; + path1 = path1.replace(/^\/+/, '').toLowerCase(); + path2 = path2.replace(/^\/+/, '').toLowerCase(); + if (path1 === path2) { + return 10000; + } + comps1 = path1.split('/').reverse(); + comps2 = path2.split('/').reverse(); + len = Math.min(comps1.length, comps2.length); + eqCount = 0; + while (eqCount < len && comps1[eqCount] === comps2[eqCount]) { + ++eqCount; + } + return eqCount; + }; + + pathsMatch = function(path1, path2) { + return numberOfMatchingSegments(path1, path2) > 0; + }; + + IMAGE_STYLES = [ + { + selector: 'background', + styleNames: ['backgroundImage'] + }, { + selector: 'border', + styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] + } + ]; + + __reloader.Reloader = Reloader = (function() { + + Reloader.name = 'Reloader'; + + function Reloader(window, console, Timer) { + this.window = window; + this.console = console; + this.Timer = Timer; + this.document = this.window.document; + this.importCacheWaitPeriod = 200; + this.plugins = []; + } + + Reloader.prototype.addPlugin = function(plugin) { + return this.plugins.push(plugin); + }; + + Reloader.prototype.analyze = function(callback) { + return results; + }; + + Reloader.prototype.reload = function(path, options) { + var plugin, _base, _i, _len, _ref; + this.options = options; + if ((_base = this.options).stylesheetReloadTimeout == null) { + _base.stylesheetReloadTimeout = 15000; + } + _ref = this.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plugin = _ref[_i]; + if (plugin.reload && plugin.reload(path, options)) { + return; + } + } + if (options.liveCSS) { + if (path.match(/\.css$/i)) { + if (this.reloadStylesheet(path)) { + return; + } + } + } + if (options.liveImg) { + if (path.match(/\.(jpe?g|png|gif)$/i)) { + this.reloadImages(path); + return; + } + } + return this.reloadPage(); + }; + + Reloader.prototype.reloadPage = function() { + return this.window.document.location.reload(); + }; + + Reloader.prototype.reloadImages = function(path) { + var expando, img, selector, styleNames, styleSheet, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3, _results; + expando = this.generateUniqueString(); + _ref = this.document.images; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + img = _ref[_i]; + if (pathsMatch(path, pathFromUrl(img.src))) { + img.src = this.generateCacheBustUrl(img.src, expando); + } + } + if (this.document.querySelectorAll) { + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + _ref1 = IMAGE_STYLES[_j], selector = _ref1.selector, styleNames = _ref1.styleNames; + _ref2 = this.document.querySelectorAll("[style*=" + selector + "]"); + for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { + img = _ref2[_k]; + this.reloadStyleImages(img.style, styleNames, path, expando); + } + } + } + if (this.document.styleSheets) { + _ref3 = this.document.styleSheets; + _results = []; + for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) { + styleSheet = _ref3[_l]; + _results.push(this.reloadStylesheetImages(styleSheet, path, expando)); + } + return _results; + } + }; + + Reloader.prototype.reloadStylesheetImages = function(styleSheet, path, expando) { + var rule, rules, styleNames, _i, _j, _len, _len1; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (!rules) { + return; + } + for (_i = 0, _len = rules.length; _i < _len; _i++) { + rule = rules[_i]; + switch (rule.type) { + case CSSRule.IMPORT_RULE: + this.reloadStylesheetImages(rule.styleSheet, path, expando); + break; + case CSSRule.STYLE_RULE: + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + styleNames = IMAGE_STYLES[_j].styleNames; + this.reloadStyleImages(rule.style, styleNames, path, expando); + } + break; + case CSSRule.MEDIA_RULE: + this.reloadStylesheetImages(rule, path, expando); + } + } + }; + + Reloader.prototype.reloadStyleImages = function(style, styleNames, path, expando) { + var newValue, styleName, value, _i, _len, + _this = this; + for (_i = 0, _len = styleNames.length; _i < _len; _i++) { + styleName = styleNames[_i]; + value = style[styleName]; + if (typeof value === 'string') { + newValue = value.replace(/\burl\s*\(([^)]*)\)/, function(match, src) { + if (pathsMatch(path, pathFromUrl(src))) { + return "url(" + (_this.generateCacheBustUrl(src, expando)) + ")"; + } else { + return match; + } + }); + if (newValue !== value) { + style[styleName] = newValue; + } + } + } + }; + + Reloader.prototype.reloadStylesheet = function(path) { + var imported, link, links, match, style, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, + _this = this; + links = (function() { + var _i, _len, _ref, _results; + _ref = this.document.getElementsByTagName('link'); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + link = _ref[_i]; + if (link.rel === 'stylesheet' && !link.__LiveReload_pendingRemoval) { + _results.push(link); + } + } + return _results; + }).call(this); + imported = []; + _ref = this.document.getElementsByTagName('style'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + style = _ref[_i]; + if (style.sheet) { + this.collectImportedStylesheets(style, style.sheet, imported); + } + } + for (_j = 0, _len1 = links.length; _j < _len1; _j++) { + link = links[_j]; + this.collectImportedStylesheets(link, link.sheet, imported); + } + if (this.window.StyleFix && this.document.querySelectorAll) { + _ref1 = this.document.querySelectorAll('style[data-href]'); + for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { + style = _ref1[_k]; + links.push(style); + } + } + this.console.log("LiveReload found " + links.length + " LINKed stylesheets, " + imported.length + " @imported stylesheets"); + match = pickBestMatch(path, links.concat(imported), function(l) { + return pathFromUrl(_this.linkHref(l)); + }); + if (match) { + if (match.object.rule) { + this.console.log("LiveReload is reloading imported stylesheet: " + match.object.href); + this.reattachImportedRule(match.object); + } else { + this.console.log("LiveReload is reloading stylesheet: " + (this.linkHref(match.object))); + this.reattachStylesheetLink(match.object); + } + } else { + this.console.log("LiveReload will reload all stylesheets because path '" + path + "' did not match any specific one"); + for (_l = 0, _len3 = links.length; _l < _len3; _l++) { + link = links[_l]; + this.reattachStylesheetLink(link); + } + } + return true; + }; + + Reloader.prototype.collectImportedStylesheets = function(link, styleSheet, result) { + var index, rule, rules, _i, _len; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (rules && rules.length) { + for (index = _i = 0, _len = rules.length; _i < _len; index = ++_i) { + rule = rules[index]; + switch (rule.type) { + case CSSRule.CHARSET_RULE: + continue; + case CSSRule.IMPORT_RULE: + result.push({ + link: link, + rule: rule, + index: index, + href: rule.href + }); + this.collectImportedStylesheets(link, rule.styleSheet, result); + break; + default: + break; + } + } + } + }; + + Reloader.prototype.waitUntilCssLoads = function(clone, func) { + var callbackExecuted, executeCallback, poll, + _this = this; + callbackExecuted = false; + executeCallback = function() { + if (callbackExecuted) { + return; + } + callbackExecuted = true; + return func(); + }; + clone.onload = function() { + console.log("onload!"); + _this.knownToSupportCssOnLoad = true; + return executeCallback(); + }; + if (!this.knownToSupportCssOnLoad) { + (poll = function() { + if (clone.sheet) { + console.log("polling!"); + return executeCallback(); + } else { + return _this.Timer.start(50, poll); + } + })(); + } + return this.Timer.start(this.options.stylesheetReloadTimeout, executeCallback); + }; + + Reloader.prototype.linkHref = function(link) { + return link.href || link.getAttribute('data-href'); + }; + + Reloader.prototype.reattachStylesheetLink = function(link) { + var clone, parent, + _this = this; + if (link.__LiveReload_pendingRemoval) { + return; + } + link.__LiveReload_pendingRemoval = true; + if (link.tagName === 'STYLE') { + clone = this.document.createElement('link'); + clone.rel = 'stylesheet'; + clone.media = link.media; + clone.disabled = link.disabled; + } else { + clone = link.cloneNode(false); + } + clone.href = this.generateCacheBustUrl(this.linkHref(link)); + parent = link.parentNode; + if (parent.lastChild === link) { + parent.appendChild(clone); + } else { + parent.insertBefore(clone, link.nextSibling); + } + return this.waitUntilCssLoads(clone, function() { + var additionalWaitingTime; + if (/AppleWebKit/.test(navigator.userAgent)) { + additionalWaitingTime = 5; + } else { + additionalWaitingTime = 200; + } + return _this.Timer.start(additionalWaitingTime, function() { + var _ref; + if (!link.parentNode) { + return; + } + link.parentNode.removeChild(link); + clone.onreadystatechange = null; + return (_ref = _this.window.StyleFix) != null ? _ref.link(clone) : void 0; + }); + }); + }; + + Reloader.prototype.reattachImportedRule = function(_arg) { + var href, index, link, media, newRule, parent, rule, tempLink, + _this = this; + rule = _arg.rule, index = _arg.index, link = _arg.link; + parent = rule.parentStyleSheet; + href = this.generateCacheBustUrl(rule.href); + media = rule.media.length ? [].join.call(rule.media, ', ') : ''; + newRule = "@import url(\"" + href + "\") " + media + ";"; + rule.__LiveReload_newHref = href; + tempLink = this.document.createElement("link"); + tempLink.rel = 'stylesheet'; + tempLink.href = href; + tempLink.__LiveReload_pendingRemoval = true; + if (link.parentNode) { + link.parentNode.insertBefore(tempLink, link); + } + return this.Timer.start(this.importCacheWaitPeriod, function() { + if (tempLink.parentNode) { + tempLink.parentNode.removeChild(tempLink); + } + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + parent.deleteRule(index + 1); + rule = parent.cssRules[index]; + rule.__LiveReload_newHref = href; + return _this.Timer.start(_this.importCacheWaitPeriod, function() { + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + return parent.deleteRule(index + 1); + }); + }); + }; + + Reloader.prototype.generateUniqueString = function() { + return 'livereload=' + Date.now(); + }; + + Reloader.prototype.generateCacheBustUrl = function(url, expando) { + var hash, oldParams, params, _ref; + if (expando == null) { + expando = this.generateUniqueString(); + } + _ref = splitUrl(url), url = _ref.url, hash = _ref.hash, oldParams = _ref.params; + if (this.options.overrideURL) { + if (url.indexOf(this.options.serverURL) < 0) { + url = this.options.serverURL + this.options.overrideURL + "?url=" + encodeURIComponent(url); + } + } + params = oldParams.replace(/(\?|&)livereload=(\d+)/, function(match, sep) { + return "" + sep + expando; + }); + if (params === oldParams) { + if (oldParams.length === 0) { + params = "?" + expando; + } else { + params = "" + oldParams + "&" + expando; + } + } + return url + params + hash; + }; + + return Reloader; + + })(); + +}).call(this); + +// livereload +var Connector, LiveReload, Options, Reloader, Timer; + +Connector = __connector.Connector; + +Timer = __timer.Timer; + +Options = __options.Options; + +Reloader = __reloader.Reloader; + +__livereload.LiveReload = LiveReload = (function() { + + function LiveReload(window) { + var _this = this; + this.window = window; + this.listeners = {}; + this.plugins = []; + this.pluginIdentifiers = {}; + this.console = this.window.location.href.match(/LR-verbose/) && this.window.console && this.window.console.log && this.window.console.error ? this.window.console : { + log: function() {}, + error: function() {} + }; + if (!(this.WebSocket = this.window.WebSocket || this.window.MozWebSocket)) { + console.error("LiveReload disabled because the browser does not seem to support web sockets"); + return; + } + if (!(this.options = Options.extract(this.window.document))) { + console.error("LiveReload disabled because it could not find its own