Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial version: no reloading, only web socket connection stuff

  • Loading branch information...
commit 710597fa7cba19840cebc636831bf92593f60c49 1 parent b53ddda
@andreyvit andreyvit authored
View
2  .gitignore
@@ -0,0 +1,2 @@
+.DS_Store
+*.js
View
11 Cakefile
@@ -0,0 +1,11 @@
+fs = require 'fs'
+path = require 'path'
+
+task 'stitch', 'Build a merged JS file', ->
+ stitch = require 'stitch'
+ package = stitch.createPackage(paths: [__dirname + "/lib"])
+
+ package.compile (err, source) ->
+ fs.writeFile __dirname + "/dist/livereload.js", source, (err) ->
+ throw err if err
+ console.log "Compiled livereload.js"
View
0  dist/.keepme
No changes.
View
0  lib/.keepme
No changes.
View
82 src/connector.coffee
@@ -0,0 +1,82 @@
+{ Parser, PROTOCOL_6, PROTOCOL_7 } = require 'protocol'
+
+exports.Connector = class Connector
+
+ constructor: (@options, @WebSocket, @Timer, @handlers) ->
+ @_uri = "ws://#{@options.host}:#{@options.port}/livereload"
+ @_nextDelay = @options.mindelay
+
+ @protocolParser = new Parser
+ connected: (protocol) =>
+ @_handshakeTimeout.stop()
+ @_nextDelay = @options.mindelay
+ @_disconnectionReason = 'broken'
+ @handlers.connected(protocol)
+ error: (e) =>
+ @handlers.error(e)
+ @_closeOnError()
+ message: (message) =>
+ @handlers.message(message)
+
+ @_handshakeTimeout = new Timer =>
+ return unless @_isSocketConnected()
+ @_disconnectionReason = 'handshake-timeout'
+ @socket.close()
+
+ @_reconnectTimer = new Timer => @connect()
+
+ @connect()
+
+
+ _isSocketConnected: ->
+ @socket and @socket.readyState is @WebSocket.OPEN
+
+ connect: ->
+ return if @_isSocketConnected()
+
+ # prepare for a new connection
+ clearTimeout @_reconnectTimer if @_reconnectTimer
+ @_disconnectionReason = 'cannot-connect'
+ @protocolParser.reset()
+
+ @handlers.connecting()
+
+ @socket = new @WebSocket(@_uri)
+ @socket.onopen = (e) => @_onopen(e)
+ @socket.onclose = (e) => @_onclose(e)
+ @socket.onmessage = (e) => @_onmessage(e)
+ @socket.onerror = (e) => @_onerror(e)
+
+ _scheduleReconnection: ->
+ unless @_reconnectTimer.running
+ @_reconnectTimer.start(@_nextDelay)
+ @_nextDelay = Math.min(@options.maxdelay, @_nextDelay * 2)
+
+ sendCommand: (command) ->
+ return unless @protocol?
+ @_sendCommand command
+
+ _sendCommand: (command) ->
+ @socket.send JSON.stringify(command)
+
+ _closeOnError: ->
+ @_handshakeTimeout.stop()
+ @_disconnectionReason = 'error'
+ @socket.close()
+
+ _onopen: (e) ->
+ @handlers.socketConnected()
+ @_disconnectionReason = 'handshake-failed'
+
+ # start handshake
+ @_sendCommand { command: 'hello', protocols: [PROTOCOL_6, PROTOCOL_7] }
+ @_handshakeTimeout.start(@options.handshake_timeout)
+
+ _onclose: (e) ->
+ @handlers.disconnected @_disconnectionReason, @_nextDelay
+ @_scheduleReconnection() unless @_disconnectionReason is 'manual'
+
+ _onerror: (e) ->
+
+ _onmessage: (e) ->
+ @protocolParser.process(e.data)
View
57 src/livereload.coffee
@@ -0,0 +1,57 @@
+{ Connector } = require 'connector'
+{ Timer } = require 'timer'
+{ Options } = require 'options'
+
+exports.LiveReload = class LiveReload
+
+ constructor: (@window) ->
+ # i can haz console?
+ @console = if @window.console && @window.console.log && @window.console.error
+ @window.console
+ else
+ log: ->
+ error: ->
+
+ # i can haz sockets?
+ unless @WebSocket = @window.WebSocket || @window.MozWebSocket
+ console.error("LiveReload disabled because the browser does not seem to support web sockets")
+ return
+
+ # i can haz options?
+ @options = Options.extract(@window.document)
+
+ # i can haz connection?
+ @connector = new Connector @options, @WebSocket, Timer,
+ connecting: ->
+
+ socketConnected: ->
+
+ connected: (protocol) ->
+ @log "LiveReload is connected to #{@options.host}:#{@options.port} (protocol v#{protocol})."
+
+ error: (e) ->
+ if e instanceof ProtocolError
+ console.log "#{e.message}."
+ else
+ console.log "LiveReload internal error: #{e.message}"
+
+ disconnected: (reason, nextDelay) =>
+ switch reason
+ when 'cannot-connect'
+ @log "LiveReload cannot connect to #{@options.host}:#{@options.port}, will retry in #{nextDelay} sec."
+ when 'broken'
+ @log "LiveReload disconnected from #{@options.host}:#{@options.port}, reconnecting in #{nextDelay} sec."
+ when 'handshake-timeout'
+ @log "LiveReload cannot connect to #{@options.host}:#{@options.port} (handshake timeout), will retry in #{nextDelay} sec."
+ when 'handshake-failed'
+ @log "LiveReload cannot connect to #{@options.host}:#{@options.port} (handshake failed), will retry in #{nextDelay} sec."
+ when 'manual' then #nop
+ when 'error' then #nop
+ else
+ @log "LiveReload disconnected from #{@options.host}:#{@options.port} (#{reason}), reconnecting in #{nextDelay} sec."
+
+ message: (message) ->
+ @log "LiveReload received message #{message.command}."
+
+ log: (message) ->
+ @console.log "LiveReload: #{message}"
View
36 src/options.coffee
@@ -0,0 +1,36 @@
+
+exports.Options = class Options
+ constructor: ->
+ @host = null
+ @port = null
+
+ @snipver = null
+ @ext = null
+ @extver = null
+
+ @mindelay = 1000
+ @maxdelay = 60000
+ @handshake_timeout = 5000
+
+ set: (name, value) ->
+ switch typeof @[name]
+ when 'undefined' then # ignore
+ when 'number'
+ @[name] = +value
+ else
+ @[name] = value
+
+Options.extract = (document) ->
+ for element in document.getElementsByTagName('script')
+ if (src = element.src) && (m = src.match ///^ https?:// ([^/:]+) : (\d+) / livereload\.js (?: \? (.*) )? $///)
+ options = new Options()
+ options.host = m[1]
+ options.port = parseInt(m[2], 10)
+
+ if m[3]
+ for pair in m[3].split('&')
+ if (keyAndValue = pair.split('=')).length > 1
+ options.set keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')
+ return options
+
+ return null
View
58 src/protocol.coffee
@@ -0,0 +1,58 @@
+
+exports.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official/6'
+exports.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official/7'
+
+exports.ProtocolError = class ProtocolError
+ constructor: (reason, data) ->
+ @message = "LiveReload protocol error (#{reason}) after receiving data: \"#{data}\"."
+
+exports.Parser = class Parser
+ constructor: (@handlers) ->
+ @reset()
+
+ reset: ->
+ @protocol = null
+
+ process: (data) ->
+ try
+ if not @protocol?
+ if data.match(///^ !!ver: ([\d.]+) $///)
+ @protocol = 6
+ else if message = @_parseMessage(data, ['hello'])
+ if !message.protocols.length
+ throw new ProtocolError("no protocols specified in handshake message")
+ else if PROTOCOL_7 in message.protocols
+ @protocol = 7
+ else if PROTOCOL_6 in message.protocols
+ @protocol = 6
+ else
+ throw new ProtocolError("no supported protocols found")
+ @handlers.connected @protocol
+ else if @protocol == 6
+ message = JSON.parse(data)
+ if !message.length
+ throw new ProtocolError("protocol 6 messages must be arrays")
+ [command, options] = message
+ if command != 'refresh'
+ throw new ProtocolError("unknown protocol 6 command")
+
+ @handlers.message command: 'reload', path: options.path, liveCSS: options.apply_css_live ? yes
+ else
+ message = @_parseMessage(data, ['reload', 'alert'])
+ @handlers.message(message)
+ catch e
+ if e instanceof ProtocolError
+ @handlers.error e
+ else
+ throw e
+
+ _parseMessage: (data, validCommands) ->
+ try
+ message = JSON.parse(data)
+ catch e
+ throw new ProtocolError('unparsable JSON', data)
+ unless message.command
+ throw new ProtocolError('missing "command" key', data)
+ unless message.command in validCommands
+ throw new ProtocolError("invalid command '#{message.command}', only valid commands are: #{validCommands.join(', ')})", data)
+ return message
View
1  src/startup.coffee
@@ -0,0 +1 @@
+window.LiveReload = new (require('livereload').LiveReload)()
View
16 src/timer.coffee
@@ -0,0 +1,16 @@
+exports.Timer = class Timer
+ constructor: (@func) ->
+ @running = no; @id = null
+ @_handler = =>
+ @running = no; @id = null
+ @func()
+
+ start: (timeout) ->
+ clearTimeout @id if @running
+ @id = setTimeout @_handler, timeout
+ @running = yes
+
+ stop: ->
+ if @running
+ clearTimeout @id
+ @running = no; @id = null
View
206 test/connector_test.coffee
@@ -0,0 +1,206 @@
+{ Options } = require 'options'
+{ Connector } = require 'connector'
+{ PROTOCOL_7 } = require 'protocol'
+
+HELLO = { command: 'hello', protocols: [PROTOCOL_7] }
+
+class MockHandlers
+ constructor: ->
+ @_log = []
+
+ obtainLog: -> result = @_log.join("\n"); @_log = []; result
+ log: (message) -> @_log.push message
+
+ connecting: -> @log "connecting"
+ socketConnected: ->
+ connected: (protocol) -> @log "connected(#{protocol})"
+ disconnected: (reason) -> @log "disconnected(#{reason})"
+ message: (message) -> @log "message(#{message.command})"
+
+newMockTimer = ->
+ class MockTimer
+ constructor: (@func) ->
+ MockTimer.timers.push this
+ @time = null
+
+ start: (timeout) ->
+ @time = MockTimer.now + timeout
+
+ stop: ->
+ @time = null
+
+ fire: ->
+ @time = null
+ @func()
+
+ MockTimer.timers = []
+ MockTimer.now = 0
+ MockTimer.advance = (period) ->
+ MockTimer.now += period
+ for timer in MockTimer.timers
+ timer.fire() if timer.time? and timer.time <= MockTimer.now
+
+ return MockTimer
+
+newMockWebSocket = ->
+ class MockWebSocket
+ constructor: ->
+ MockWebSocket._last = this
+ @sent = []
+ @readyState = MockWebSocket.CONNECTING
+
+ obtainSent: -> result = @sent; @sent = []; result
+ log: (message) -> @_log.push message
+
+ send: (message) -> @sent.push message
+
+ close: ->
+ @readyState = MockWebSocket.CLOSED
+ @onclose({})
+
+ connected: ->
+ @readyState = MockWebSocket.OPEN
+ @onopen({})
+
+ disconnected: ->
+ @readyState = MockWebSocket.CLOSED
+ @onclose({})
+
+ receive: (message) ->
+ @onmessage({ data: message })
+
+ assertMessages: (assert, messages) ->
+ actual = []
+ expected = []
+
+ keys = []
+ for message in messages
+ for own key, value of message
+ keys.push key unless key in keys
+ keys.sort()
+
+ for payload in @sent
+ message = JSON.parse(payload)
+ actual.push ("#{key} = #{JSON.stringify(message[key])}" for key in keys when message.hasOwnProperty(key))
+ for message in messages
+ expected.push ("#{key} = #{JSON.stringify(message[key])}" for key in keys when message.hasOwnProperty(key))
+
+ assert.equal expected.join("\n"), actual.join("\n")
+ @sent = []
+
+ MockWebSocket.last = -> result = MockWebSocket._last; MockWebSocket._last = null; result
+
+ MockWebSocket.CONNECTING = 0
+ MockWebSocket.OPEN = 1
+ MockWebSocket.CLOSED = 2
+
+ return MockWebSocket
+
+
+shouldBeConnecting = (assert, handlers) ->
+ assert.equal "connecting", handlers.obtainLog()
+
+shouldReconnect = (assert, handlers, timer, failed, code) ->
+ if failed
+ delays = [1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000, 60000]
+ else
+ delays = [1000, 1000, 1000]
+ for delay in delays
+ timer.advance delay-100
+ assert.equal "", handlers.obtainLog()
+
+ timer.advance 100
+ shouldBeConnecting assert, handlers
+
+ code()
+
+cannotConnect = (assert, handlers, webSocket) ->
+ assert.isNotNull (ws = webSocket.last())
+ ws.disconnected()
+ assert.equal "disconnected(cannot-connect)", handlers.obtainLog()
+
+connectionBroken = (assert, handlers, ws) ->
+ ws.disconnected()
+ assert.equal "disconnected(broken)", handlers.obtainLog()
+
+connectAndPerformHandshake = (assert, handlers, webSocket, func) ->
+ assert.isNotNull (ws = webSocket.last())
+
+ ws.connected()
+ ws.assertMessages assert, [{ command: 'hello' }]
+ assert.equal "", handlers.obtainLog()
+
+ ws.receive JSON.stringify(HELLO)
+ assert.equal "connected(7)", handlers.obtainLog()
+
+ func?(ws)
+
+connectAndTimeoutHandshake = (assert, handlers, timer, webSocket, func) ->
+ assert.isNotNull (ws = webSocket.last())
+
+ ws.connected()
+ ws.assertMessages assert, [{ command: 'hello' }]
+ assert.equal "", handlers.obtainLog()
+
+ timer.advance 5000
+ assert.equal "disconnected(handshake-timeout)", handlers.obtainLog()
+
+sendReload = (assert, handlers, ws) ->
+ ws.receive JSON.stringify({ command: 'reload', path: 'foo.css' })
+ assert.equal "message(reload)", handlers.obtainLog()
+
+
+exports['should connect and perform handshake'] = (beforeExit, assert) ->
+ handlers = new MockHandlers()
+ options = new Options()
+ timer = newMockTimer()
+ webSocket = newMockWebSocket()
+ connector = new Connector(options, webSocket, timer, handlers)
+
+ shouldBeConnecting assert, handlers
+ connectAndPerformHandshake assert, handlers, webSocket, (ws) ->
+ sendReload assert, handlers, ws
+
+
+exports['should repeat connection attempts'] = (beforeExit, assert) ->
+ handlers = new MockHandlers()
+ options = new Options()
+ timer = newMockTimer()
+ webSocket = newMockWebSocket()
+ connector = new Connector(options, webSocket, timer, handlers)
+
+ shouldBeConnecting assert, handlers
+ cannotConnect assert, handlers, webSocket
+
+ shouldReconnect assert, handlers, timer, yes, ->
+ cannotConnect assert, handlers, webSocket
+
+
+exports['should reconnect after disconnection'] = (beforeExit, assert) ->
+ handlers = new MockHandlers()
+ options = new Options()
+ timer = newMockTimer()
+ webSocket = newMockWebSocket()
+ connector = new Connector(options, webSocket, timer, handlers)
+
+ shouldBeConnecting assert, handlers
+ connectAndPerformHandshake assert, handlers, webSocket, (ws) ->
+ connectionBroken assert, handlers, ws
+
+ shouldReconnect assert, handlers, timer, no, ->
+ connectAndPerformHandshake assert, handlers, webSocket, (ws) ->
+ connectionBroken assert, handlers, ws
+
+
+exports['should timeout handshake after 5 sec'] = (beforeExit, assert) ->
+ handlers = new MockHandlers()
+ options = new Options()
+ timer = newMockTimer()
+ webSocket = newMockWebSocket()
+ connector = new Connector(options, webSocket, timer, handlers)
+
+ shouldBeConnecting assert, handlers
+ connectAndTimeoutHandshake assert, handlers, timer, webSocket
+
+ shouldReconnect assert, handlers, timer, yes, ->
+ connectAndTimeoutHandshake assert, handlers, timer, webSocket
View
52 test/options_test.coffee
@@ -0,0 +1,52 @@
+{ Options } = require 'options'
+jsdom = require 'jsdom'
+
+
+exports['should extract host and port from a SCRIPT tag'] = (beforeExit, assert) ->
+ _loaded = no
+ jsdom.env """
+ <script src="http://somewhere.com:9876/livereload.js"></script>
+ """, [], (errors, window) ->
+ assert.isNull errors
+ _loaded = yes
+
+ options = Options.extract(window.document)
+ assert.isNotNull options
+ assert.equal 'somewhere.com', options.host
+ assert.equal 9876, options.port
+
+ beforeExit -> assert.ok _loaded
+
+
+exports['should pick the correct SCRIPT tag'] = (beforeExit, assert) ->
+ _loaded = no
+ jsdom.env """
+ <script src="http://elsewhere.com:1234/livesomething.js"></script>
+ <script src="http://somewhere.com:9876/livereload.js"></script>
+ <script src="http://elsewhere.com:1234/dontreload.js"></script>
+ """, [], (errors, window) ->
+ assert.isNull errors
+ _loaded = yes
+
+ options = Options.extract(window.document)
+ assert.isNotNull options
+ assert.equal 'somewhere.com', options.host
+ assert.equal 9876, options.port
+
+ beforeExit -> assert.ok _loaded
+
+
+exports['should extract additional options'] = (beforeExit, assert) ->
+ _loaded = no
+ jsdom.env """
+ <script src="http://somewhere.com:9876/livereload.js?snipver=1&ext=Safari&extver=2.0"></script>
+ """, [], (errors, window) ->
+ assert.isNull errors
+ _loaded = yes
+
+ options = Options.extract(window.document)
+ assert.equal '1', options.snipver
+ assert.equal 'Safari', options.ext
+ assert.equal '2.0', options.extver
+
+ beforeExit -> assert.ok _loaded
View
48 test/protocol_test.coffee
@@ -0,0 +1,48 @@
+{ Parser } = require 'protocol'
+
+class MockHandler
+ constructor: ->
+ @_log = []
+ @gotError = no
+
+ obtainLog: -> result = @_log.join("\n"); @_log = []; result
+ log: (message) -> @_log.push message
+
+ connected: (@protocol) ->
+ error: (@error) -> @gotError = yes
+
+ message: (msg) ->
+ switch msg.command
+ when 'reload' then @log "reload(#{msg.path})"
+ else @log msg.commmand
+
+
+exports['should reject a bogus handshake'] = (beforeExit, assert) ->
+ handler = new MockHandler()
+ parser = new Parser(handler)
+
+ parser.process 'boo'
+ assert.ok handler.gotError
+
+
+exports['should speak protocol 6'] = (beforeExit, assert) ->
+ handler = new MockHandler()
+ parser = new Parser(handler)
+
+ parser.process '!!ver:1.6'
+ assert.equal 6, parser.protocol
+
+ parser.process '[ "refresh", { "path": "foo.css" } ]'
+ assert.equal "reload(foo.css)", handler.obtainLog()
+
+
+exports['should speak protocol 7'] = (beforeExit, assert) ->
+ handler = new MockHandler()
+ parser = new Parser(handler)
+
+ parser.process '{ "command": "hello", "protocols": [ "http://livereload.com/protocols/official/7" ] }'
+ assert.equal null, handler.error?.message
+ assert.equal 7, parser.protocol
+
+ parser.process '{ "command": "reload", "path": "foo.css" }'
+ assert.equal "reload(foo.css)", handler.obtainLog()
View
41 test/timer_test.coffee
@@ -0,0 +1,41 @@
+{ Timer } = require 'timer'
+
+
+exports['timer should fire an event once in due time'] = (beforeExit, assert) ->
+ fired = 0
+ timer = new Timer ->
+ ++fired
+
+ assert.equal no, timer.running
+ timer.start(20)
+ assert.equal yes, timer.running
+
+ beforeExit ->
+ assert.equal 1, fired
+
+
+exports['timer should not fire after it is stopped'] = (beforeExit, assert) ->
+ fired = 0
+ timer = new Timer ->
+ ++fired
+
+ timer.start(20)
+ setTimeout((-> timer.stop()), 10)
+
+ beforeExit ->
+ assert.equal 0, fired
+
+
+exports['timer should restart interval on each start() call'] = (beforeExit, assert) ->
+ okToFire = no
+ fired = 0
+ timer = new Timer ->
+ assert.equal yes, okToFire
+ ++fired
+
+ timer.start(10)
+ setTimeout((-> timer.start(100)), 5)
+ setTimeout((-> okToFire = yes), 15)
+
+ beforeExit ->
+ assert.equal 1, fired
Please sign in to comment.
Something went wrong with that request. Please try again.