Add a minimal livereload server implementation

Add a minimal server to support livereload.com browser plugins. Includes
a partial websocket implementation to save needing a dependency to
spring-websocket.

See gh-3085
This commit is contained in:
Phillip Webb
2015-06-01 13:23:11 -07:00
parent 3d8db7cddb
commit f09134180e
14 changed files with 2653 additions and 0 deletions

View File

@@ -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]);
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 <a href="http://livereload.com">livereload</a> server.
*
* @author Phillip Webb
* @see <a href="http://livereload.com">livereload.com</a>
* @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<Connection> connections = new ArrayList<Connection>();
/**
* 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;
}
}
}

View File

@@ -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;