rails_live_reload 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 453d155a64c2869fea44301e05aa33cf32274ff501cfdb6a7eb1c061bb5cffa1
4
- data.tar.gz: b7e734f2649d12365e8e346e2656fd386aea36187d6a295f19f8643460138d6d
3
+ metadata.gz: 999e453ba10fc303a5f317609ad52abbb318972712802ea87606f17c89c2054a
4
+ data.tar.gz: 456e9df00f34d48e75a8cdbee655e035e68a0c98c468020946dc3ec6903848bb
5
5
  SHA512:
6
- metadata.gz: 7519b3b746a4c58eb412e97743a24db2b39ae7e99351bff81edcfe69df682478e0c250f5b9f5a08022fd1c086f571df0bc52cfaa530bf0c1996c1d1240456ea7
7
- data.tar.gz: 347cae5497f229eb42a38cd9e62f3fc8c33b1c2f03542af5546a156a54f80fff7e5fa4a16e3253e69a44b6e5df9ebe25e5930fd03e19a9d941d2be7a43fb431b
6
+ metadata.gz: 9d3e4ddad3250f1cb40b06d0f17c69b537d25c26ebe90b3844f942540af27286c9d2f66d4a29562d2ffd9c725d0286b028da8a645ad43126ac57099a13b567bd
7
+ data.tar.gz: a7ac90c951252374f0d6cd357d822c33d8c2a330f2b2b487f3e048551b439168474a4da4dd822228644ea7d1cfdbfdc04e5064efab9957c6a55c8a455b1f1d1f
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  This is the simplest and probably the most robust way to add live reloading to your Rails app.
8
8
 
9
- Just add the gem and thats it, now you have a live reloading. No 3rd party dependencies.
9
+ Just add the gem and thats it, now you have a live reloading. **You don't need anything other than this gem for live reloading to work**.
10
10
 
11
11
  Works with:
12
12
 
@@ -40,9 +40,12 @@ $ bundle
40
40
 
41
41
  ## Configuration
42
42
 
43
- ### There are two modes:
44
- 1. `:long_polling` - This is a default mode, it uses [long polling](https://javascript.info/long-polling) techunique, client opens a connection that will hang until either change is detected, or timeout happens, if later, a new connection is oppened
45
- 2. `:polling` - This mode will use regular polling to detect changes, you can configure custom `polling_interval` (default is 100ms). We recommend using `:long_polling` as it makes much less requests to the server.
43
+ ### There are three modes:
44
+ 1. `:websocket` - This is a default mode which uses websockets to trigger page reloading.
45
+ 2. `:long_polling` - **[Deprecated]** This mode uses [long polling](https://javascript.info/long-polling) techunique, client opens a connection that will hang until either change is detected, or timeout happens, if later, a new connection is oppened.
46
+ 3. `:polling` - **[Deprecated]** This mode will use regular polling to detect changes, you can configure custom `polling_interval` (default is 100ms). We recommend using `:long_polling` as it makes much less requests to the server.
47
+
48
+ Keep in mind that `mode` configuration is **deprecated** and will be removed in the future, with `:websocket` be the only one available, in case you think that modes like `:long_polling` or `:polling` should be preserved feel free to open an issue.
46
49
 
47
50
  ### Create initializer `config/initializers/rails_live_reload.rb`:
48
51
 
@@ -50,8 +53,12 @@ $ bundle
50
53
  ```ruby
51
54
  RailsLiveReload.configure do |config|
52
55
  # config.url = "/rails/live/reload"
53
- # Available modes are: :long_polling (default) and :polling
54
- # config.mode = :long_polling
56
+ # Available modes are: :websocket (default), :long_polling and :polling
57
+ # config.mode = :websocket
58
+
59
+ # This is used with :long_polling mode
60
+ # config.timeout = 30
61
+ # config.long_polling_sleep_duration = 0.1
55
62
 
56
63
  # This is used with :polling mode
57
64
  # config.polling_interval = 100
@@ -63,6 +70,8 @@ RailsLiveReload.configure do |config|
63
70
  # More examples:
64
71
  # config.watch %r{app/helpers/.+\.rb}, reload: :always
65
72
  # config.watch %r{config/locales/.+\.yml}, reload: :always
73
+
74
+ # config.enabled = Rails.env.development?
66
75
  end if defined?(RailsLiveReload)
67
76
  ```
68
77
 
@@ -72,7 +81,7 @@ There are 3 main parts:
72
81
 
73
82
  1) listener of file changes (using `listen` gem)
74
83
  2) collector of rendered views (see rails instrumentation)
75
- 3) middleware which is responding to polling JS calls
84
+ 3) JavaScript client that communicates with server and triggers reloading when needed
76
85
 
77
86
  ## Notes
78
87
 
@@ -85,15 +94,12 @@ You are welcome to contribute. See list of `TODO's` below.
85
94
  ## TODO
86
95
 
87
96
  - reload CSS without reloading the whole page?
88
- - add `:websocket` mode?
89
97
  - smarter reload if there is a change in helper (check methods from rendered views?)
90
98
  - generator for initializer
91
99
  - more complex rules? e.g. if "user.rb" file is changed - reload all pages with rendered "users" views
92
100
  - check with older Rails versions
93
101
  - tests or specs
94
102
  - CI (github actions)
95
- - improve how JS code is injected into HTML
96
- - improve work with turbo, turbolinks
97
103
 
98
104
  ## License
99
105
 
@@ -18,22 +18,54 @@ module RailsLiveReload
18
18
  end
19
19
 
20
20
  class Config
21
- attr_reader :patterns
22
- attr_accessor :mode, :polling_interval, :timeout, :url, :watcher, :files, :enabled
21
+ attr_reader :patterns, :polling_interval, :timeout, :mode, :long_polling_sleep_duration
22
+ attr_accessor :url, :watcher, :files, :enabled
23
23
 
24
24
  def initialize
25
- @mode = :long_polling
25
+ @mode = :websocket
26
26
  @timeout = 30
27
- @patterns = {}
27
+ @long_polling_sleep_duration = 0.1
28
28
  @polling_interval = 100
29
29
  @url = "/rails/live/reload"
30
30
  @watcher = nil
31
31
  @files = {}
32
- @enabled = ::Rails.env.development? && !defined?(Rails::Console)
32
+ @enabled = ::Rails.env.development?
33
+
34
+ # These configs work for 95% apps, see README for more info
35
+ @patterns = {
36
+ %r{app/views/.+\.(erb|haml|slim)$} => :on_change,
37
+ %r{(app|vendor)/(assets|javascript)/\w+/(.+\.(css|js|html|png|jpg|ts|jsx)).*} => :always
38
+ }
39
+ @default_patterns_changed = false
33
40
  end
34
41
 
35
42
  def watch(pattern, reload: :on_change)
43
+ unless @default_patterns_changed
44
+ @default_patterns_changed = true
45
+ @patterns = {}
46
+ end
47
+
36
48
  patterns[pattern] = reload
37
49
  end
50
+
51
+ def mode=(mode)
52
+ warn "[DEPRECATION] RailsLiveReload 'mode' configuration is deprecated and will be removed in future versions #{caller.first}"
53
+ @mode = mode
54
+ end
55
+
56
+ def polling_interval=(polling_interval)
57
+ warn "[DEPRECATION] RailsLiveReload 'polling_interval' configuration is deprecated and will be removed in future versions #{caller.first}"
58
+ @polling_interval = polling_interval
59
+ end
60
+
61
+ def timeout=(timeout)
62
+ warn "[DEPRECATION] RailsLiveReload 'timeout' configuration is deprecated and will be removed in future versions #{caller.first}"
63
+ @timeout = timeout
64
+ end
65
+
66
+ def long_polling_sleep_duration=(long_polling_sleep_duration)
67
+ warn "[DEPRECATION] RailsLiveReload 'long_polling_sleep_duration' configuration is deprecated and will be removed in future versions #{caller.first}"
68
+ @long_polling_sleep_duration = long_polling_sleep_duration
69
+ end
38
70
  end
39
71
  end
@@ -1,7 +1,7 @@
1
1
  module RailsLiveReload
2
2
  class Railtie < ::Rails::Engine
3
3
 
4
- if RailsLiveReload.enabled?
4
+ if RailsLiveReload.enabled? && defined?(::Rails::Server)
5
5
  initializer "rails_live_reload.middleware" do |app|
6
6
  if ::Rails::VERSION::MAJOR.to_i >= 5
7
7
  app.middleware.insert_after ActionDispatch::Executor, RailsLiveReload.middleware
@@ -0,0 +1,6 @@
1
+ /*!
2
+ Rails Live Reload 0.2.0
3
+ Copyright © 2022 RailsJazz
4
+ https://railsjazz.com
5
+ */
6
+ var RailsLiveReload=function(){"use strict";const t="RELOAD";class e{static _instance;static get instance(){return e._instance||(e._instance=new this),e._instance}static start(){this.instance.start()}constructor(){this.initialize(),document.addEventListener("turbo:render",(()=>{document.documentElement.hasAttribute("data-turbo-preview")||this.restart()})),document.addEventListener("turbolinks:render",(()=>{document.documentElement.hasAttribute("data-turbolinks-preview")||this.restart()}))}initialize(){const{files:t,time:e,url:s,options:i}=JSON.parse(this.optionsNode.textContent);this.files=t,this.time=e,this.url=s,this.options=i}start(){throw"This should be implemented in subclass"}stop(){throw"This should be implemented in subclass"}restart(){this.stop(),this.initialize(),this.start()}fullReload(){window.location.reload()}get optionsNode(){const t=document.getElementById("rails-live-reload-options");if(!t)throw"Unable to find RailsLiveReload options";return t}}class s extends e{start(){this.retriesCount=0,this.timestamp=new Date,this.poll(this.timestamp)}stop(){this.timestamp=void 0}async poll(e){if(this.timestamp===e)try{const s=new FormData;s.append("dt",this.time),s.append("files",JSON.stringify(this.files));const i=await fetch(this.url,{method:"post",headers:{Accept:"application/json"},body:s}),n=await i.json();if(this.timestamp!==e)return;this.retriesCount=0,n.command===t?this.fullReload():this.poll(e)}catch(t){if(this.timestamp!==e)return;this.retriesCount++,this.retriesCount<10?setTimeout((()=>this.poll(e)),5e3):this.stop()}}}return document.addEventListener("DOMContentLoaded",(()=>{s.start()})),s}();
@@ -0,0 +1,6 @@
1
+ /*!
2
+ Rails Live Reload 0.2.0
3
+ Copyright © 2022 RailsJazz
4
+ https://railsjazz.com
5
+ */
6
+ var RailsLiveReload=function(){"use strict";const t="RELOAD";class e{static _instance;static get instance(){return e._instance||(e._instance=new this),e._instance}static start(){this.instance.start()}constructor(){this.initialize(),document.addEventListener("turbo:render",(()=>{document.documentElement.hasAttribute("data-turbo-preview")||this.restart()})),document.addEventListener("turbolinks:render",(()=>{document.documentElement.hasAttribute("data-turbolinks-preview")||this.restart()}))}initialize(){const{files:t,time:e,url:i,options:s}=JSON.parse(this.optionsNode.textContent);this.files=t,this.time=e,this.url=i,this.options=s}start(){throw"This should be implemented in subclass"}stop(){throw"This should be implemented in subclass"}restart(){this.stop(),this.initialize(),this.start()}fullReload(){window.location.reload()}get optionsNode(){const t=document.getElementById("rails-live-reload-options");if(!t)throw"Unable to find RailsLiveReload options";return t}}class i extends e{start(){this.interval||(this.interval=setInterval((async()=>{const e=new FormData;e.append("dt",this.time),e.append("files",JSON.stringify(this.files));const i=await fetch(this.url,{method:"post",headers:{Accept:"application/json"},body:e});(await i.json()).command===t&&(this.stop(),this.fullReload())}),this.options.polling_interval))}restart(){this.initialize()}stop(){clearInterval(this.interval),this.interval=void 0}}return document.addEventListener("DOMContentLoaded",(()=>{i.start()})),i}();
@@ -0,0 +1,6 @@
1
+ /*!
2
+ Rails Live Reload 0.2.0
3
+ Copyright © 2022 RailsJazz
4
+ https://railsjazz.com
5
+ */
6
+ var RailsLiveReload=function(){"use strict";const t="RELOAD";class e{static _instance;static get instance(){return e._instance||(e._instance=new this),e._instance}static start(){this.instance.start()}constructor(){this.initialize(),document.addEventListener("turbo:render",(()=>{document.documentElement.hasAttribute("data-turbo-preview")||this.restart()})),document.addEventListener("turbolinks:render",(()=>{document.documentElement.hasAttribute("data-turbolinks-preview")||this.restart()}))}initialize(){const{files:t,time:e,url:n,options:i}=JSON.parse(this.optionsNode.textContent);this.files=t,this.time=e,this.url=n,this.options=i}start(){throw"This should be implemented in subclass"}stop(){throw"This should be implemented in subclass"}restart(){this.stop(),this.initialize(),this.start()}fullReload(){window.location.reload()}get optionsNode(){const t=document.getElementById("rails-live-reload-options");if(!t)throw"Unable to find RailsLiveReload options";return t}}const n=["rails-live-reload-v1-json"];class i extends e{start(){this.connection||(this.connection=new WebSocket(function(t){if("function"==typeof t&&(t=t()),t&&!/^wss?:/i.test(t)){const e=document.createElement("a");return e.href=t,e.href=e.href,e.protocol=e.protocol.replace("http","ws"),e.href}return t}(this.url),n),this.connection.onmessage=this.handleMessage.bind(this),this.connection.onopen=this.handleConnectionOpen.bind(this),this.connection.onclose=this.handleConnectionClosed.bind(this))}stop(){this.connection.close()}restart(){this.initialize(),this.setupConnection()}setupConnection(){this.connection.send(JSON.stringify({event:"setup",options:{files:this.files,dt:this.time}}))}handleConnectionOpen(t){this.retriesCount=0,this.setupConnection()}handleMessage(e){JSON.parse(e.data).command===t&&this.fullReload()}handleConnectionClosed(t){this.connection=void 0,!t.wasClean&&this.retriesCount<=10&&(this.retriesCount++,setTimeout((()=>{this.start()}),1e3*this.retriesCount))}}return document.addEventListener("DOMContentLoaded",(()=>{i.start()})),i}();
@@ -0,0 +1,75 @@
1
+ module RailsLiveReload
2
+ module Middleware
3
+ class Base
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ dup.call!(env)
10
+ end
11
+
12
+ def call!(env)
13
+ request = Rack::Request.new(env)
14
+
15
+ case env["REQUEST_PATH"]
16
+ when RailsLiveReload.config.url
17
+ main_rails_live_response(request)
18
+ when "#{RailsLiveReload.config.url}/script"
19
+ content = client_javascript
20
+ [200, {'Content-Type' => 'application/javascript', 'Content-Length' => content.size.to_s, 'Cache-Control' => 'no-store'}, [content]]
21
+ else
22
+ status, headers, response = @app.call(env)
23
+
24
+ if html?(headers) && response.respond_to?(:[]) && (status == 500 || (status.to_s =~ /20./ && request.get?))
25
+ new_response = make_new_response(response[0])
26
+ headers['Content-Length'] = new_response.bytesize.to_s
27
+ response = [new_response]
28
+ end
29
+
30
+ [status, headers, response]
31
+ end
32
+ rescue Exception => ex
33
+ puts ex.message
34
+ puts ex.backtrace.take(10)
35
+ raise ex
36
+ end
37
+
38
+ private
39
+
40
+ def main_rails_live_response(request)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def client_javascript
45
+ @client_javascript ||= File.open(File.join(File.dirname(__FILE__), "../javascript/#{RailsLiveReload.config.mode}.js")).read
46
+ end
47
+
48
+ def make_new_response(body)
49
+ body = body.sub("</head>", <<~HTML.html_safe)
50
+ <script defer type="text/javascript" src="#{RailsLiveReload.config.url}/script"></script>
51
+ </head>
52
+ HTML
53
+ body.sub("</body>", <<~HTML.html_safe)
54
+ <script id="rails-live-reload-options" type="application/json">
55
+ #{{
56
+ files: CurrentRequest.current.data.to_a,
57
+ time: Time.now.to_i,
58
+ url: RailsLiveReload.config.url,
59
+ options: javascript_options
60
+ }.to_json}
61
+ </script>
62
+ </body>
63
+ HTML
64
+ end
65
+
66
+ def javascript_options
67
+ {}
68
+ end
69
+
70
+ def html?(headers)
71
+ headers["Content-Type"].to_s.include?("text/html")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,45 @@
1
+ module RailsLiveReload
2
+ module Middleware
3
+ class LongPolling < Base
4
+ private
5
+
6
+ def main_rails_live_response(request)
7
+ params = request.params
8
+ body = lambda do |stream|
9
+ new_thread do
10
+ counter = 0
11
+
12
+ loop do
13
+ command = RailsLiveReload::Command.new(params)
14
+
15
+ if command.reload?
16
+ stream.write(command.payload.to_json) and break
17
+ end
18
+
19
+ sleep(RailsLiveReload.config.long_polling_sleep_duration)
20
+ counter += 1
21
+
22
+ stream.write(command.payload.to_json) and break if counter >= max_sleeps_count
23
+ end
24
+ ensure
25
+ stream.close
26
+ end
27
+ end
28
+
29
+ [ 200, { 'Content-Type' => 'application/json', 'rack.hijack' => body }, nil ]
30
+ end
31
+
32
+ def max_sleeps_count
33
+ RailsLiveReload.config.timeout * (1 / RailsLiveReload.config.long_polling_sleep_duration)
34
+ end
35
+
36
+ def new_thread
37
+ Thread.new {
38
+ t2 = Thread.current
39
+ t2.abort_on_exception = true
40
+ yield
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ module RailsLiveReload
2
+ module Middleware
3
+ class Polling < Base
4
+ private
5
+
6
+ def main_rails_live_response(request)
7
+ [
8
+ 200,
9
+ { 'Content-Type' => 'application/json' },
10
+ [ RailsLiveReload::Command.new(request.params).payload.to_json ]
11
+ ]
12
+ end
13
+
14
+ def javascript_options
15
+ { polling_interval: RailsLiveReload.config.polling_interval }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ require 'rails_live_reload/web_socket/event_loop'
2
+ require 'rails_live_reload/web_socket/message_buffer'
3
+ require 'rails_live_reload/web_socket/wrapper'
4
+ require 'rails_live_reload/web_socket/client_socket'
5
+ require 'rails_live_reload/web_socket/stream'
6
+ require 'rails_live_reload/web_socket/base'
7
+
8
+ module RailsLiveReload
9
+ module Middleware
10
+ class WebSocket < Base
11
+ attr_reader :mutex, :event_loop
12
+
13
+ delegate :connections, :add_connection, :remove_connection, to: :class
14
+
15
+ class << self
16
+ def connections
17
+ @connections ||= []
18
+ end
19
+
20
+ def add_connection(connection)
21
+ connections << connection
22
+ end
23
+
24
+ def remove_connection(connection)
25
+ connections.delete connection
26
+ end
27
+ end
28
+
29
+ def initialize(env)
30
+ @mutex = Monitor.new
31
+ @event_loop = nil
32
+ super
33
+ end
34
+
35
+ def main_rails_live_response(request)
36
+ setup_heartbeat_timer
37
+ RailsLiveReload::WebSocket::Base.new(self, request).process
38
+ end
39
+
40
+ def setup_heartbeat_timer
41
+ @heartbeat_timer ||= event_loop.timer(3) do
42
+ event_loop.post { connections.each(&:beat) }
43
+ end
44
+ end
45
+
46
+ def event_loop
47
+ @event_loop || @mutex.synchronize { @event_loop ||= RailsLiveReload::WebSocket::EventLoop.new }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsLiveReload
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -27,6 +27,7 @@ module RailsLiveReload
27
27
  all.each do |file|
28
28
  files[file] = File.mtime(file).to_i rescue nil
29
29
  end
30
+ RailsLiveReload::Middleware::WebSocket.connections.each(&:reload)
30
31
  end
31
32
  listener.start
32
33
  end
@@ -0,0 +1,116 @@
1
+ module RailsLiveReload
2
+ module WebSocket
3
+ # This class is strongly based on ActionCable
4
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/connection/base.rb
5
+ class Base
6
+ attr_reader :server, :env, :protocol, :request
7
+ attr_reader :dt, :files
8
+
9
+ delegate :event_loop, to: :server
10
+
11
+ def initialize(server, request)
12
+ @server, @request = server, request
13
+ @env = request.env
14
+ @websocket = RailsLiveReload::WebSocket::Wrapper.new(env, self, event_loop)
15
+ @message_buffer = RailsLiveReload::WebSocket::MessageBuffer.new(self)
16
+ end
17
+
18
+ def process
19
+ if websocket.possible?
20
+ respond_to_successful_request
21
+ else
22
+ respond_to_invalid_request
23
+ end
24
+ end
25
+
26
+ def receive(websocket_message)
27
+ if websocket.alive?
28
+ handle_channel_command decode(websocket_message)
29
+ end
30
+ end
31
+
32
+ def handle_channel_command(payload)
33
+ case payload['event']
34
+ when 'setup'
35
+ setup payload['options']
36
+ else
37
+ raise NotImplementedError
38
+ end
39
+ end
40
+
41
+ def reload
42
+ return if dt.nil? || files.nil? || RailsLiveReload::Checker.scan(dt, files).size.zero?
43
+
44
+ transmit({command: "RELOAD"})
45
+ end
46
+
47
+ def transmit(cable_message)
48
+ websocket.transmit encode(cable_message)
49
+ end
50
+
51
+ def close(reason: nil)
52
+ transmit({
53
+ type: RailsLiveReload::INTERNAL[:message_types][:disconnect],
54
+ reason: reason
55
+ })
56
+ websocket.close
57
+ end
58
+
59
+ def beat
60
+ transmit type: RailsLiveReload::INTERNAL[:message_types][:ping], message: Time.now.to_i
61
+ end
62
+
63
+ def on_open
64
+ @protocol = websocket.protocol
65
+ send_welcome_message
66
+
67
+ message_buffer.process!
68
+ server.add_connection(self)
69
+ end
70
+
71
+ def on_message(message)
72
+ message_buffer.append message
73
+ end
74
+
75
+ def on_error(message)
76
+ raise message
77
+ end
78
+
79
+ def on_close(reason, code)
80
+ server.remove_connection(self)
81
+ end
82
+
83
+ private
84
+
85
+ attr_reader :websocket
86
+ attr_reader :message_buffer
87
+
88
+ def setup(options)
89
+ @dt = options['dt']
90
+ @files = options['files']
91
+ end
92
+
93
+ def encode(message)
94
+ message.to_json
95
+ end
96
+
97
+ def decode(message)
98
+ JSON.parse message
99
+ end
100
+
101
+ def send_welcome_message
102
+ transmit type: RailsLiveReload::INTERNAL[:message_types][:welcome]
103
+ end
104
+
105
+ def respond_to_successful_request
106
+ websocket.rack_response
107
+ end
108
+
109
+ def respond_to_invalid_request
110
+ close(reason: RailsLiveReload::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive?
111
+
112
+ [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,153 @@
1
+ require "websocket/driver"
2
+
3
+ module RailsLiveReload
4
+ module WebSocket
5
+ # This class is basically copied from ActionCable
6
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/connection/client_socket.rb
7
+ class ClientSocket
8
+ def self.determine_url(env)
9
+ scheme = secure_request?(env) ? "wss:" : "ws:"
10
+ "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }"
11
+ end
12
+
13
+ def self.secure_request?(env)
14
+ return true if env["HTTPS"] == "on"
15
+ return true if env["HTTP_X_FORWARDED_SSL"] == "on"
16
+ return true if env["HTTP_X_FORWARDED_SCHEME"] == "https"
17
+ return true if env["HTTP_X_FORWARDED_PROTO"] == "https"
18
+ return true if env["rack.url_scheme"] == "https"
19
+
20
+ false
21
+ end
22
+
23
+ CONNECTING = 0
24
+ OPEN = 1
25
+ CLOSING = 2
26
+ CLOSED = 3
27
+
28
+ attr_reader :env, :url
29
+
30
+ def initialize(env, event_target, event_loop, protocols)
31
+ @env = env
32
+ @event_target = event_target
33
+ @event_loop = event_loop
34
+
35
+ @url = ClientSocket.determine_url(@env)
36
+
37
+ @driver = @driver_started = nil
38
+ @close_params = ["", 1006]
39
+
40
+ @ready_state = CONNECTING
41
+
42
+ @driver = ::WebSocket::Driver.rack(self, protocols: protocols)
43
+
44
+ @driver.on(:open) { |e| open }
45
+ @driver.on(:message) { |e| receive_message(e.data) }
46
+ @driver.on(:close) { |e| begin_close(e.reason, e.code) }
47
+ @driver.on(:error) { |e| emit_error(e.message) }
48
+
49
+ @stream = RailsLiveReload::WebSocket::Stream.new(@event_loop, self)
50
+ end
51
+
52
+ def start_driver
53
+ return if @driver.nil? || @driver_started
54
+ @stream.hijack_rack_socket
55
+
56
+ if callback = @env["async.callback"]
57
+ callback.call([101, {}, @stream])
58
+ end
59
+
60
+ @driver_started = true
61
+ @driver.start
62
+ end
63
+
64
+ def rack_response
65
+ start_driver
66
+ [ -1, {}, [] ]
67
+ end
68
+
69
+ def write(data)
70
+ @stream.write(data)
71
+ rescue => e
72
+ emit_error e.message
73
+ end
74
+
75
+ def transmit(message)
76
+ return false if @ready_state > OPEN
77
+ case message
78
+ when Numeric then @driver.text(message.to_s)
79
+ when String then @driver.text(message)
80
+ when Array then @driver.binary(message)
81
+ else false
82
+ end
83
+ end
84
+
85
+ def close(code = nil, reason = nil)
86
+ code ||= 1000
87
+ reason ||= ""
88
+
89
+ unless code == 1000 || (code >= 3000 && code <= 4999)
90
+ raise ArgumentError, "Failed to execute 'close' on WebSocket: " \
91
+ "The code must be either 1000, or between 3000 and 4999. " \
92
+ "#{code} is neither."
93
+ end
94
+
95
+ @ready_state = CLOSING unless @ready_state == CLOSED
96
+ @driver.close(reason, code)
97
+ end
98
+
99
+ def parse(data)
100
+ @driver.parse(data)
101
+ end
102
+
103
+ def client_gone
104
+ finalize_close
105
+ end
106
+
107
+ def alive?
108
+ @ready_state == OPEN
109
+ end
110
+
111
+ def protocol
112
+ @driver.protocol
113
+ end
114
+
115
+ private
116
+
117
+ def open
118
+ return unless @ready_state == CONNECTING
119
+ @ready_state = OPEN
120
+
121
+ @event_target.on_open
122
+ end
123
+
124
+ def receive_message(data)
125
+ return unless @ready_state == OPEN
126
+
127
+ @event_target.on_message(data)
128
+ end
129
+
130
+ def emit_error(message)
131
+ return if @ready_state >= CLOSING
132
+
133
+ @event_target.on_error(message)
134
+ end
135
+
136
+ def begin_close(reason, code)
137
+ return if @ready_state == CLOSED
138
+ @ready_state = CLOSING
139
+ @close_params = [reason, code]
140
+
141
+ @stream.shutdown if @stream
142
+ finalize_close
143
+ end
144
+
145
+ def finalize_close
146
+ return if @ready_state == CLOSED
147
+ @ready_state = CLOSED
148
+
149
+ @event_target.on_close(*@close_params)
150
+ end
151
+ end
152
+ end
153
+ end