From f09134180e5ded63ad4cd39a55f9c27b7b662166 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:23:11 -0700 Subject: [PATCH] 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 --- .../livereload/Base64Encoder.java | 62 + .../developertools/livereload/Connection.java | 162 +++ .../livereload/ConnectionClosedException.java | 32 + .../livereload/ConnectionInputStream.java | 102 ++ .../livereload/ConnectionOutputStream.java | 59 + .../boot/developertools/livereload/Frame.java | 159 +++ .../livereload/LiveReloadServer.java | 322 +++++ .../livereload/package-info.java | 21 + .../developertools/livereload/livereload.js | 1055 +++++++++++++++++ .../livereload/Base64EncoderTests.java | 53 + .../ConnectionInputStreamTests.java | 103 ++ .../ConnectionOutputStreamTests.java | 73 ++ .../developertools/livereload/FrameTests.java | 188 +++ .../livereload/LiveReloadServerTests.java | 262 ++++ 14 files changed, 2653 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java create mode 100644 spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/Base64EncoderTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/ConnectionInputStreamTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/ConnectionOutputStreamTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/FrameTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/LiveReloadServerTests.java 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 0000000000..4453d01352 --- /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 0000000000..04c36df8bd --- /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 0000000000..0916c3a4fa --- /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 0000000000..a86c338fa4 --- /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 0000000000..4a3f14fc82 --- /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 0000000000..138957d8dd --- /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 0000000000..49420b607b --- /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 0000000000..64c1937a3f --- /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/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 0000000000..edb280265c --- /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