/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.jooby.livereload;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import org.jooby.Env;
import org.jooby.Jooby.Module;
import org.jooby.MediaType;
import org.jooby.Route;
import org.jooby.Router;
import org.jooby.WebSocket;
import org.jooby.filewatcher.FileWatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.CaseFormat;
import com.google.common.collect.ImmutableSet;
import com.google.inject.Binder;
import com.typesafe.config.Config;
import javaslang.Tuple;
import javaslang.Tuple2;
/**
* <h1>liveReload</h1>
* <p>
* <a href="http://livereload.com">LiveReload</a> monitors changes in the file system. As soon as
* you save a file, it is preprocessed as
* needed, and the browser is refreshed.
* </p>
* <p>
* Even cooler, when you change a CSS file or an image, the browser is updated instantly without
* reloading the page.
* </p>
*
* <h2>usage</h2>
* <pre>{@code
* {
* use(new Jackson());
*
* use(new LiveReload());
* }
* }</pre>
*
* <p>
* This module is available as long you run in development: <code>application.env=dev</code>.
* </p>
*
* <h2>configuration</h2>
*
* <h3>browser extension</h3>
* <p>
* Install the <a href="http://livereload.com/extensions/">LiveReload browser extension.</a>
* </p>
*
* <h3>livereload.js</h3>
* <p>
* Add the <code>livereload.js</code> to your web page.
* </p>
*
* <p>
* You can do this manually:
* </p>
*
* <pre>{@code
* <script src="/livereload.js"></script>
* }</pre>
*
* <p>
* Or use the <code>liveReload</code> local variable with your preferred template engine. Here is an
* example using <code>Handlebars</code>:
* </p>
*
* <pre>
* {{liveReload}}
* </pre>
*
* <h3>json module</h3>
* <p>
* The <a href=
* "http://feedback.livereload.com/knowledgebase/articles/86174-livereload-protocol">LiveReload
* Protocol</a> run on top of a <code>WebSocket</code> and uses <code>JSON</code> as protocol
* format. That's is why we also need to install a <code>JSON module</code>, like <a
* href="http://jooby.org/doc/jackson">Jackson</a>.
* </p>
*
* <h2>watcher</h2>
* <p>
* It automatically reload static resources from <code>public</code>, <code>target</code> (Maven
* projects) or <code>build</code> folders (Gradle projects).
* </p>
*
* <p>
* Every time a change is detected the websocket send a <code>reload command</code>.
* </p>
*
* <p>
* That's all folks!!
* </p>
*
* @author edgar
* @since 1.1.0
*/
public class LiveReload implements Module {
public static final String V7 = "http://livereload.com/protocols/official-7";
/** The logging system. */
private final Logger log = LoggerFactory.getLogger(getClass());
private static final Set<String> CSS = ImmutableSet.of(".css", ".scss", ".sass", ".less");
private Predicate<Path> css = path -> CSS.stream()
.filter(ext -> path.toString().endsWith(ext))
.findFirst()
.isPresent();
private List<Tuple2<Path, List<String>>> paths = new ArrayList<>();
/**
* Creates a new {@link LiveReload} module.
*/
public LiveReload() {
}
/**
* Add the given path to the watcher.
*
* @param path Path to watch.
* @param includes Glob pattern of matching files.
* @return This module.
*/
public LiveReload register(final Path path, final String... includes) {
if (Files.exists(path)) {
paths.add(Tuple.of(path, Arrays.asList(includes)));
}
return this;
}
/**
* Test if a path is a css like file (less, sass, etc.).
*
* @param predicate CSS predicate.
* @return This module.
*/
public LiveReload liveCss(final Predicate<Path> predicate) {
this.css = predicate;
return this;
}
@SuppressWarnings("unchecked")
@Override
public void configure(final Env env, final Config conf, final Binder binder) throws Throwable {
boolean enabled = conf.hasPath("livereload.enabled")
? conf.getBoolean("livereload.enabled")
: "dev".equals(env.name());
if (enabled) {
Router router = env.router();
/**
* Livereload client:
*/
String livereloadjs = "/" + LiveReload.class.getPackage().getName().replace(".", "/")
+ "/livereload.js";
router.assets("/livereload.js", livereloadjs);
/** {{liveReload}} local variable */
router.use("*", (req, rsp) -> req.set("liveReload",
"<script src=\"" + req.contextPath() + "/livereload.js\"></script>"))
.name("livereload");
String serverName = CaseFormat.LOWER_CAMEL
.to(CaseFormat.UPPER_CAMEL, conf.getString("application.name"));
Queue<WebSocket> broadcast = new ConcurrentLinkedQueue<>();
AtomicBoolean first = new AtomicBoolean(true);
/**
* Websocket:
*/
router.ws("/livereload", ws -> {
// add to broadcast
broadcast.add(ws);
ws.onMessage(msg -> {
Map<String, Object> cmd = msg.to(Map.class);
log.debug("command: {}", cmd);
Map<String, Object> rsp = handshake(cmd, serverName, V7);
if (rsp != null) {
log.debug("sending: {}", rsp);
ws.send(rsp);
} else {
log.trace("ignoring command: {}", cmd);
// resync app state after a jooby:run restart
if (first.compareAndSet(true, false)) {
int counter = Integer.parseInt(System.getProperty("joobyRun.counter", "0"));
if (counter > 0) {
ws.send(reload("/", true));
}
}
}
});
// remove from broadcast
ws.onClose(reason -> broadcast.remove(ws));
}).consumes(MediaType.json).produces(MediaType.json);
if (paths.isEmpty()) {
register(Paths.get("public"),
"**/*.css",
"**/*.scss",
"**/*.sass",
"**/*.less",
"**/*.html",
"**/*.js",
"**/*.coffee",
"**/*.ts");
register(Paths.get("target"),
"**/*.class",
"**/*.conf",
"**/*.properties");
register(Paths.get("build"),
"**/*.class",
"**/*.conf",
"**/*.properties");
}
FileWatcher watcher = new FileWatcher();
paths.forEach(it -> watcher.register(it._1, (kind, path) -> {
Path relative = relative(paths, path.toAbsolutePath());
log.debug("file changed {}: {}", relative, File.separator);
Map<String, Object> reload = reload(
Route.normalize("/" + relative.toString().replace(File.separator, "/")),
css.test(relative));
for (WebSocket ws : broadcast) {
try {
log.info("sending: {}", reload);
ws.send(reload);
} catch (Exception x) {
log.debug("execution of {} resulted in exception", reload, x);
}
}
}, options -> it._2.forEach(options::includes)));
watcher.configure(env, conf, binder);
}
}
private boolean isHello(final Map<String, Object> message) {
return "hello".equals(message.get("command"));
}
@SuppressWarnings("unchecked")
private Map<String, Object> handshake(final Map<String, Object> client,
final String serverName, final String version) {
if (isHello(client)) {
List<String> protocols = (List<String>) client.get("protocols");
return protocols.stream()
.filter(protocol -> version.equalsIgnoreCase(protocol))
.map(protocol -> {
Map<String, Object> server = new LinkedHashMap<>();
server.put("command", "hello");
server.put("protocols", new String[]{protocol });
server.put("serverName", serverName);
return server;
})
.findFirst()
.orElse(null);
}
return null;
}
private Path relative(final List<Tuple2<Path, List<String>>> paths, final Path path) {
for (Tuple2<Path, List<String>> meta : paths) {
Path root = meta._1.toAbsolutePath();
Path relative = root.relativize(path);
if (Files.exists(root.resolve(relative))) {
return relative;
}
}
return path;
}
private Map<String, Object> reload(final String path, final boolean liveCss) {
Map<String, Object> cmd = new LinkedHashMap<>();
cmd.put("command", "reload");
cmd.put("path", path);
cmd.put("liveCSS", liveCss);
return cmd;
}
}