Commit 4a3b6309 authored by Mathijs den Burger's avatar Mathijs den Burger

CMS7-8367: initial implementation of the auto-reload service

The service provided a JavaScript snippet that lets a browser connect to the auto-reload server using WebSockets. Whenever #broadcastPageReload is called, a 'reloadPage' message is sent to all connected browsers that will then reload the current page. 

When a WebSocket connection is closed unexpectedly (e.g. because the server is stopped), the JavaScript snippet tries to reconnect every five seconds for 10 minutes, and then gives up.

The auto-reload service is enabled by default. It can be toggled via the configuration parameter 'enabled'. Changes to the JCR configuration are picked up and effective immediately. The auto-reload service can also be disabled via code using the #setEnabled method.

The auto-reload service is supposed to be injected into Tomcat's shared/lib folder by Cargo, so it'll only be available during local development. For that reason only dependencies can be used that are available in the shared/lib folder. The WebSockets API implementation is provided by Tomcat.
parent 00b8e521
* text=auto !eol
src/main/java/org/onehippo/cms7/services/autoreload/AutoReloadServiceModule.java -text
src/main/resources/autoreload-service-module.xml -text
src/main/resources/hippoecm-extension.xml -text
/*.iml
/.idea
/target
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2014 Hippo B.V. (http://www.onehippo.com)
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.onehippo.cms7</groupId>
<artifactId>hippo-cms7-project</artifactId>
<version>27-SNAPSHOT</version>
</parent>
<name>Hippo CMS7 Services - autoreload</name>
<artifactId>hippo-services-autoreload</artifactId>
<version>1.05.00-SNAPSHOT</version>
<inceptionYear>2014</inceptionYear>
<properties>
<!-- use root project name for all project modules NOTICE files, should be the same as in the root NOTICE file -->
<notice.project.name>Hippo CMS7 Services - autoreload</notice.project.name>
<!-- runtime dependencies -->
<hippo.repository.version>2.27.00-SNAPSHOT</hippo.repository.version>
<hippo.services.version>1.05.00-SNAPSHOT</hippo.services.version>
<javax.websocket-api.version>1.0</javax.websocket-api.version>
<!-- test dependencies -->
<commons-io.version>1.4</commons-io.version>
<commons-lang.version>2.6</commons-lang.version>
<easymock.version>3.0</easymock.version>
<junit.version>4.11</junit.version>
</properties>
<scm>
<connection>scm:svn:http://svn.onehippo.org/repos/hippo/hippo-cms7/services/services-webresources/trunk</connection>
<developerConnection>scm:svn:https://svn.onehippo.org/repos/hippo/hippo-cms7/services/services-webresources/trunk</developerConnection>
<url>http://svn.onehippo.org/repos/hippo/hippo-cms7/services/services-webresources/trunk</url>
</scm>
<repositories>
<repository>
<id>hippo</id>
<name>Hippo Maven 2</name>
<url>http://maven.onehippo.com/maven2/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
<releases>
<updatePolicy>never</updatePolicy>
</releases>
</repository>
</repositories>
<dependencies>
<!--
N.B. since the auto-reload service is deployed in Tomcat's shared/lib directory,
only dependencies available in Tomcat's shared/lib directory can be used here.
-->
<dependency>
<groupId>org.onehippo.cms7</groupId>
<artifactId>hippo-repository-api</artifactId>
<version>${hippo.repository.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.onehippo.cms7</groupId>
<artifactId>hippo-services</artifactId>
<version>${hippo.services.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>${javax.websocket-api.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.easymock</groupId>
<artifactId>easymock</artifactId>
<version>${easymock.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>${commons-lang.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.onehippo.cms7</groupId>
<artifactId>hippo-repository-testutils</artifactId>
<version>${hippo.repository.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Loads the auto-reload JavaScript snippet.
*/
class AutoReloadScriptLoader {
private static final String AUTO_RELOAD_SCRIPT = "autoreload.js";
private static final Logger log = LoggerFactory.getLogger(AutoReloadScriptLoader.class);
/**
* @return the auto-reload JavaScript snippet, or null if the snippet could not be loaded.
*/
String getJavaScript() {
InputStream input = null;
try {
input = AutoReloadScriptLoader.class.getResourceAsStream(AUTO_RELOAD_SCRIPT);
if (input == null) {
log.error("Could not locate " + AUTO_RELOAD_SCRIPT);
} else {
return readString(input, "UTF-8");
}
} catch (IOException e) {
log.warn("Error while loading " + AUTO_RELOAD_SCRIPT, e);
} finally {
closeQuietly(input);
}
log.warn("Script {} could not be loaded", AUTO_RELOAD_SCRIPT);
return null;
}
private String readString(final InputStream in, final String encoding) throws IOException {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final byte[] buffer = new byte[1024];
int length = 0;
while ((length = in.read(buffer)) != -1) {
out.write(buffer, 0, length);
}
return new String(out.toByteArray(), encoding);
}
private void closeQuietly(final InputStream input) {
try {
input.close();
} catch (IOException e) {
log.debug("Ignored exception while closing input stream", e);
}
}
}
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import java.io.IOException;
import java.util.concurrent.ConcurrentLinkedQueue;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import org.onehippo.cms7.services.HippoServiceRegistry;
import org.onehippo.cms7.services.eventbus.HippoEventBus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ServerEndpoint(value = "/autoreload", configurator = AutoReloadServerConfigurator.class)
public class AutoReloadServer {
private static final String RELOAD_PAGE_MESSAGE_JSON = "{\"command\":\"reloadPage\"}";
private static final Logger log = LoggerFactory.getLogger(AutoReloadServer.class);
private static AutoReloadServer instance = null;
private final ConcurrentLinkedQueue<Session> sessions;
private AutoReloadServer() {
sessions = new ConcurrentLinkedQueue();
HippoServiceRegistry.registerService(this, HippoEventBus.class);
log.info("auto-reload server created");
}
static synchronized AutoReloadServer getInstance() {
if (instance == null) {
instance = new AutoReloadServer();
}
return instance;
}
@OnOpen
public void onOpen(final Session session) {
sessions.add(session);
log.info("auto-reload connection '{}' opened, #connections = {}", session.getId(), sessions.size());
}
@OnMessage
public void onMessage(final Session session, final String msg) {
log.warn("closing auto-reload connection, unexpected message: '{}'", msg);
closeQuitely(session);
}
private void closeQuitely(final Session session) {
try {
session.close();
} catch (IOException e) {
log.debug("Error while closing auto-reload connection:", e);
}
}
@OnClose
public void onClose(final Session session) {
sessions.remove(session);
log.info("auto-reload connection '{}' closed, #connections = {}, ", session.getId(), sessions.size());
}
@OnError
public void onError(final Throwable t) {
log.debug("ignoring auto-reload websocket error:", t);
}
void broadcastPageReload() {
log.debug("broadcasting page reload");
broadcast(RELOAD_PAGE_MESSAGE_JSON);
}
private void broadcast(final String message) {
for (Session session : sessions) {
session.getAsyncRemote().sendText(message);
}
}
}
\ No newline at end of file
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import javax.websocket.server.ServerEndpointConfig;
/**
* Ensures that the same instance of the {@link org.onehippo.cms7.services.autoreload.AutoReloadServer}
* is used for each web socket connection.
*/
public class AutoReloadServerConfigurator extends ServerEndpointConfig.Configurator {
@Override
public <T> T getEndpointInstance(final Class<T> endpointClass) throws InstantiationException {
if (endpointClass.equals(AutoReloadServer.class)) {
return (T) AutoReloadServer.getInstance();
}
throw new InstantiationException("Cannot create instance of " + endpointClass);
}
}
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import org.hippoecm.repository.util.JcrUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* JCR-based, thread-safe, reconfigurable configuration of the auto-reload service.
*/
class AutoReloadServiceConfig {
private static final String CONFIG_ENABLED = "enabled";
private static final boolean DEFAULT_ENABLED = true;
private static final Logger log = LoggerFactory.getLogger(AutoReloadServiceConfig.class);
private AtomicBoolean isEnabled = new AtomicBoolean(DEFAULT_ENABLED);
void reconfigure(final Node config) throws RepositoryException {
isEnabled.set(JcrUtils.getBooleanProperty(config, CONFIG_ENABLED, DEFAULT_ENABLED));
log.info("Reconfigured auto-reload service: enabled=" + isEnabled);
}
public boolean isEnabled() {
return isEnabled.get();
}
}
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Auto-reload service based on web sockets.
*/
class AutoReloadServiceImpl implements AutoReloadService {
private final AutoReloadServiceConfig config;
private final AutoReloadScriptLoader scriptLoader;
private final AutoReloadServer autoReloadServer;
private final AtomicBoolean enabled;
private final String cachedJavaScript;
AutoReloadServiceImpl(final AutoReloadServiceConfig config, final AutoReloadScriptLoader scriptLoader, final AutoReloadServer autoReloadServer) {
this.config = config;
this.scriptLoader = scriptLoader;
this.autoReloadServer = autoReloadServer;
enabled = new AtomicBoolean(config.isEnabled());
cachedJavaScript = scriptLoader.getJavaScript();
}
@Override
public boolean isEnabled() {
return cachedJavaScript != null && enabled.get() && config.isEnabled();
}
@Override
public void setEnabled(final boolean isEnabled) {
enabled.set(isEnabled);
}
@Override
public String getJavaScript() {
return cachedJavaScript;
}
@Override
public void broadcastPageReload() {
if (isEnabled()) {
autoReloadServer.broadcastPageReload();
}
}
}
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.onehippo.cms7.services.autoreload;
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import org.onehippo.cms7.services.HippoServiceRegistry;
import org.onehippo.repository.modules.AbstractReconfigurableDaemonModule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AutoReloadServiceModule extends AbstractReconfigurableDaemonModule {
private static final Logger log = LoggerFactory.getLogger(AutoReloadServiceModule.class);
private final AutoReloadServiceConfig config;
private AutoReloadService service;
public AutoReloadServiceModule() {
config = new AutoReloadServiceConfig();
}
@Override
protected void doConfigure(final Node moduleConfig) throws RepositoryException {
config.reconfigure(moduleConfig);
log.info("Automatic reload of browsers is {}", config.isEnabled() ? "enabled" : "disabled");
}
@Override
protected void doInitialize(final Session session) throws RepositoryException {
service = new AutoReloadServiceImpl(config, new AutoReloadScriptLoader(), AutoReloadServer.getInstance());
HippoServiceRegistry.registerService(service, AutoReloadService.class);
}
@Override
protected void doShutdown() {
if (service != null) {
HippoServiceRegistry.unregisterService(service, AutoReloadService.class);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2014 Hippo B.V. (http://www.onehippo.com)
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.
-->
<sv:node xmlns:sv="http://www.jcp.org/jcr/sv/1.0" sv:name="autoreload">
<sv:property sv:name="jcr:primaryType" sv:type="Name">
<sv:value>hipposys:module</sv:value>
</sv:property>
<sv:property sv:name="hipposys:className" sv:type="String">
<sv:value>org.onehippo.cms7.services.autoreload.AutoReloadServiceModule</sv:value>
</sv:property>
<sv:node sv:name="hippo:moduleconfig">
<sv:property sv:name="jcr:primaryType" sv:type="Name">
<sv:value>hipposys:moduleconfig</sv:value>
</sv:property>
<sv:property sv:name="enabled" sv:type="Boolean">
<sv:value>true</sv:value>
</sv:property>
</sv:node>
</sv:node>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2014 Hippo B.V. (http://www.onehippo.com)
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.
-->
<sv:node xmlns:sv="http://www.jcp.org/jcr/sv/1.0" sv:name="hippo:initialize">
<sv:property sv:name="jcr:primaryType" sv:type="Name">
<sv:value>hippo:initializefolder</sv:value>
</sv:property>
<sv:node sv:name="autoreload-service-module">
<sv:property sv:name="jcr:primaryType" sv:type="Name">
<sv:value>hippo:initializeitem</sv:value>
</sv:property>
<sv:property sv:name="hippo:contentresource" sv:type="String">
<sv:value>autoreload-service-module.xml</sv:value>
</sv:property>
<sv:property sv:name="hippo:contentroot" sv:type="String">
<sv:value>/hippo:configuration/hippo:modules</sv:value>
</sv:property>
<sv:property sv:name="hippo:sequence" sv:type="Double">
<sv:value>13</sv:value>
</sv:property>
</sv:node>
<sv:node sv:name="autoreload-hst-hosts-prefix-exclusion">
<sv:property sv:name="jcr:primaryType" sv:type="Name">
<sv:value>hippo:initializeitem</sv:value>
</sv:property>
<sv:property sv:name="hippo:contentroot" sv:type="String">
<sv:value>/hst:hst/hst:hosts/hst:prefixexclusions</sv:value>
</sv:property>
<sv:property sv:name="hippo:contentpropadd" sv:type="String">
<sv:value>/autoreload</sv:value>
</sv:property>
<sv:property sv:name="hippo:sequence" sv:type="Double">
<!--
/hst:hst/hst:hosts is bootstrapped in end projects, so use a very high
sequence number to ensure this initialize item is bootstrapped later
-->
<sv:value>100000</sv:value>
</sv:property>
</sv:node>
</sv:node>
\ No newline at end of file
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*
* 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.
*/
(function(window, console) {
var AUTO_RELOAD_PATH = "/autoreload",
RECONNECT_DELAY_MILLIS = 5000,
MAX_RECONNECT_ATTEMPTS = 120, // retry for 120 * 5000 ms = 10 minutes
isReloadingPage,
isReconnecting,
reconnectAttempts = 0,
websocket;
function reloadPage() {
window.document.location.reload();
}
function onOpen() {
isReloadingPage = false;
isReconnecting = false;
console.debug("Hippo auto-reload enabled");
}
function onMessage(event) {
var message = JSON.parse(event.data);
if (message.command === "reloadPage") {
console.debug("Hippo auto-reload is reloading page...");
isReloadingPage = true;
websocket.close();
reloadPage();
} else {
console.debug("Hippo auto-reload received unknown message:", message);
}
}
function onError(event) {
if (!isReconnecting) {
var warning = "Hippo auto-reload error";
if (event.data) {
warning += ": " + event.data;
}
console.debug(warning);
}
}
function connect() {
websocket = new window.WebSocket(serverUrl());
websocket.onopen = onOpen;
websocket.onmessage = onMessage;
websocket.onerror = onError;
websocket.onclose = onClose;
}
function disconnect() {
if (websocket) {
isReloadingPage = true;
websocket.close();
}
}
function onClose(event) {
if (!isReloadingPage) {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
isReconnecting = true;
reconnectAttempts++;
console.debug("Hippo auto-reload disconnected, trying to reconnect...");
window.setTimeout(connect, RECONNECT_DELAY_MILLIS);
} else {
isReconnecting = false;
console.debug("Hippo auto-reload stopped trying to reconnect.");
}
}
}
function serverUrl() {
var contextPath = document.location.pathname.split('/').slice(0, 2).join('/');
return "ws://" + document.location.host + contextPath + AUTO_RELOAD_PATH;
}
function init() {
}
if (window.addEventListener && window.WebSocket) {
window.addEventListener("load", connect);
window.addEventListener("unload", disconnect);
} else if (console.log) {
console.log("Hippo auto-reload is not available because this browser does not support WebSockets")
}
}(window, console));
/*
* Copyright 2014 Hippo B.V. (http://www.onehippo.com)
*