rails_live_reload 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04c014926a43e8cba8e586fa5aca93a4e002357fffee35d2a553db0ec88f04a0
4
- data.tar.gz: 7c5b64509ab169603cd32b62adc9390eb2f1d939d2e626e9bca960f325d45876
3
+ metadata.gz: 999e453ba10fc303a5f317609ad52abbb318972712802ea87606f17c89c2054a
4
+ data.tar.gz: 456e9df00f34d48e75a8cdbee655e035e68a0c98c468020946dc3ec6903848bb
5
5
  SHA512:
6
- metadata.gz: 6e365d7ffad4d351c35c84f8839c4b55318c9769c2191c8aab53cd441253d26950cfabeb98f76c8b556e7bb80d9b329f0b6e5d81ce1c4b0841239d106b38a406
7
- data.tar.gz: ec660e96b97075b0c7663e5d0b3be0e59d3a505805feda8c0e7262c2e9c7889f560f0a308edaecf1dc03aaeb7e3a987e709718196ae7b42d47b98df03074449f
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,8 @@ $ 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
55
58
 
56
59
  # This is used with :long_polling mode
57
60
  # config.timeout = 30
@@ -78,7 +81,7 @@ There are 3 main parts:
78
81
 
79
82
  1) listener of file changes (using `listen` gem)
80
83
  2) collector of rendered views (see rails instrumentation)
81
- 3) middleware which is responding to polling JS calls
84
+ 3) JavaScript client that communicates with server and triggers reloading when needed
82
85
 
83
86
  ## Notes
84
87
 
@@ -91,15 +94,12 @@ You are welcome to contribute. See list of `TODO's` below.
91
94
  ## TODO
92
95
 
93
96
  - reload CSS without reloading the whole page?
94
- - add `:websocket` mode?
95
97
  - smarter reload if there is a change in helper (check methods from rendered views?)
96
98
  - generator for initializer
97
99
  - more complex rules? e.g. if "user.rb" file is changed - reload all pages with rendered "users" views
98
100
  - check with older Rails versions
99
101
  - tests or specs
100
102
  - CI (github actions)
101
- - improve how JS code is injected into HTML
102
- - improve work with turbo, turbolinks
103
103
 
104
104
  ## License
105
105
 
@@ -18,11 +18,11 @@ 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, :long_polling_sleep_duration
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
27
  @long_polling_sleep_duration = 0.1
28
28
  @polling_interval = 100
@@ -47,5 +47,25 @@ module RailsLiveReload
47
47
 
48
48
  patterns[pattern] = reload
49
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
50
70
  end
51
71
  end
@@ -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.2"
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
@@ -0,0 +1,131 @@
1
+ require "nio"
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/stream_event_loop.rb
7
+ class EventLoop
8
+ def initialize
9
+ @nio = @executor = @thread = nil
10
+ @map = {}
11
+ @stopping = false
12
+ @todo = Queue.new
13
+ @spawn_mutex = Mutex.new
14
+ end
15
+
16
+ def timer(interval, &block)
17
+ Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
18
+ end
19
+
20
+ def post(task = nil, &block)
21
+ task ||= block
22
+
23
+ spawn
24
+ @executor << task
25
+ end
26
+
27
+ def attach(io, stream)
28
+ @todo << lambda do
29
+ @map[io] = @nio.register(io, :r)
30
+ @map[io].value = stream
31
+ end
32
+ wakeup
33
+ end
34
+
35
+ def detach(io, stream)
36
+ @todo << lambda do
37
+ @nio.deregister io
38
+ @map.delete io
39
+ io.close
40
+ end
41
+ wakeup
42
+ end
43
+
44
+ def writes_pending(io)
45
+ @todo << lambda do
46
+ if monitor = @map[io]
47
+ monitor.interests = :rw
48
+ end
49
+ end
50
+ wakeup
51
+ end
52
+
53
+ def stop
54
+ @stopping = true
55
+ wakeup if @nio
56
+ end
57
+
58
+ private
59
+
60
+ def spawn
61
+ return if @thread && @thread.status
62
+
63
+ @spawn_mutex.synchronize do
64
+ return if @thread && @thread.status
65
+
66
+ @nio ||= NIO::Selector.new
67
+
68
+ @executor ||= Concurrent::ThreadPoolExecutor.new(
69
+ min_threads: 1,
70
+ max_threads: 10,
71
+ max_queue: 0,
72
+ )
73
+
74
+ @thread = Thread.new { run }
75
+
76
+ return true
77
+ end
78
+ end
79
+
80
+ def wakeup
81
+ spawn || @nio.wakeup
82
+ end
83
+
84
+ def run
85
+ loop do
86
+ if @stopping
87
+ @nio.close
88
+ break
89
+ end
90
+
91
+ until @todo.empty?
92
+ @todo.pop(true).call
93
+ end
94
+
95
+ next unless monitors = @nio.select
96
+
97
+ monitors.each do |monitor|
98
+ io = monitor.io
99
+ stream = monitor.value
100
+
101
+ begin
102
+ if monitor.writable?
103
+ if stream.flush_write_buffer
104
+ monitor.interests = :r
105
+ end
106
+ next unless monitor.readable?
107
+ end
108
+
109
+ incoming = io.read_nonblock(4096, exception: false)
110
+ case incoming
111
+ when :wait_readable
112
+ next
113
+ when nil
114
+ stream.close
115
+ else
116
+ stream.receive incoming
117
+ end
118
+ rescue
119
+ begin
120
+ stream.close
121
+ rescue
122
+ @nio.deregister io
123
+ @map.delete io
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,53 @@
1
+ module RailsLiveReload
2
+ module WebSocket
3
+ # This class is basically copied from ActionCable
4
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/connection/message_buffer.rb
5
+ class MessageBuffer
6
+ def initialize(connection)
7
+ @connection = connection
8
+ @buffered_messages = []
9
+ end
10
+
11
+ def append(message)
12
+ if valid? message
13
+ if processing?
14
+ receive message
15
+ else
16
+ buffer message
17
+ end
18
+ else
19
+ raise ArgumentError, "Couldn't handle non-string message: #{message.class}"
20
+ end
21
+ end
22
+
23
+ def processing?
24
+ @processing
25
+ end
26
+
27
+ def process!
28
+ @processing = true
29
+ receive_buffered_messages
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :connection, :buffered_messages
35
+
36
+ def valid?(message)
37
+ message.is_a?(String)
38
+ end
39
+
40
+ def receive(message)
41
+ connection.receive message
42
+ end
43
+
44
+ def buffer(message)
45
+ buffered_messages << message
46
+ end
47
+
48
+ def receive_buffered_messages
49
+ receive buffered_messages.shift until buffered_messages.empty?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,109 @@
1
+ module RailsLiveReload
2
+ module WebSocket
3
+ # This class is basically copied from ActionCable
4
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/connection/stream.rb
5
+ class Stream
6
+ def initialize(event_loop, socket)
7
+ @event_loop = event_loop
8
+ @socket_object = socket
9
+ @stream_send = socket.env["stream.send"]
10
+
11
+ @rack_hijack_io = nil
12
+ @write_lock = Mutex.new
13
+
14
+ @write_head = nil
15
+ @write_buffer = Queue.new
16
+ end
17
+
18
+ def each(&callback)
19
+ @stream_send ||= callback
20
+ end
21
+
22
+ def close
23
+ shutdown
24
+ @socket_object.client_gone
25
+ end
26
+
27
+ def shutdown
28
+ clean_rack_hijack
29
+ end
30
+
31
+ def write(data)
32
+ if @stream_send
33
+ return @stream_send.call(data)
34
+ end
35
+
36
+ if @write_lock.try_lock
37
+ begin
38
+ if @write_head.nil? && @write_buffer.empty?
39
+ written = @rack_hijack_io.write_nonblock(data, exception: false)
40
+
41
+ case written
42
+ when :wait_writable
43
+ when data.bytesize
44
+ return data.bytesize
45
+ else
46
+ @write_head = data.byteslice(written, data.bytesize)
47
+ @event_loop.writes_pending @rack_hijack_io
48
+
49
+ return data.bytesize
50
+ end
51
+ end
52
+ ensure
53
+ @write_lock.unlock
54
+ end
55
+ end
56
+
57
+ @write_buffer << data
58
+ @event_loop.writes_pending @rack_hijack_io
59
+
60
+ data.bytesize
61
+ rescue EOFError, Errno::ECONNRESET
62
+ @socket_object.client_gone
63
+ end
64
+
65
+ def flush_write_buffer
66
+ @write_lock.synchronize do
67
+ loop do
68
+ if @write_head.nil?
69
+ return true if @write_buffer.empty?
70
+ @write_head = @write_buffer.pop
71
+ end
72
+
73
+ written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
74
+ case written
75
+ when :wait_writable
76
+ return false
77
+ when @write_head.bytesize
78
+ @write_head = nil
79
+ else
80
+ @write_head = @write_head.byteslice(written, @write_head.bytesize)
81
+ return false
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def receive(data)
88
+ @socket_object.parse(data)
89
+ end
90
+
91
+ def hijack_rack_socket
92
+ return unless @socket_object.env["rack.hijack"]
93
+
94
+ @rack_hijack_io = @socket_object.env["rack.hijack"].call
95
+ @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
96
+
97
+ @event_loop.attach(@rack_hijack_io, self)
98
+ end
99
+
100
+ private
101
+
102
+ def clean_rack_hijack
103
+ return unless @rack_hijack_io
104
+ @event_loop.detach(@rack_hijack_io, self)
105
+ @rack_hijack_io = nil
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,27 @@
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/web_socket.rb
7
+ class Wrapper
8
+ delegate :transmit, :close, :protocol, :rack_response, to: :websocket
9
+
10
+ def initialize(env, event_target, event_loop, protocols: RailsLiveReload::INTERNAL[:protocols])
11
+ @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil
12
+ end
13
+
14
+ def possible?
15
+ websocket
16
+ end
17
+
18
+ def alive?
19
+ websocket && websocket.alive?
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :websocket
25
+ end
26
+ end
27
+ end
@@ -1,11 +1,11 @@
1
1
  require "listen"
2
2
  require "rails_live_reload/version"
3
3
  require "rails_live_reload/config"
4
- require "rails_live_reload/client"
5
4
  require "rails_live_reload/watcher"
6
- require "rails_live_reload/rails/middleware/base"
7
- require "rails_live_reload/rails/middleware/long_polling"
8
- require "rails_live_reload/rails/middleware/polling"
5
+ require "rails_live_reload/middleware/base"
6
+ require "rails_live_reload/middleware/long_polling"
7
+ require "rails_live_reload/middleware/web_socket"
8
+ require "rails_live_reload/middleware/polling"
9
9
  require "rails_live_reload/instrument/metrics_collector"
10
10
  require "rails_live_reload/thread/current_request"
11
11
  require "rails_live_reload/checker"
@@ -16,10 +16,24 @@ module RailsLiveReload
16
16
  mattr_accessor :watcher
17
17
  @@watcher = {}
18
18
 
19
+ INTERNAL = {
20
+ message_types: {
21
+ welcome: "welcome",
22
+ disconnect: "disconnect",
23
+ ping: "ping",
24
+ },
25
+ disconnect_reasons: {
26
+ invalid_request: "invalid_request",
27
+ remote: "remote"
28
+ },
29
+ protocols: ["rails-live-reload-v1-json"].freeze
30
+ }
31
+
19
32
  def self.middleware
20
33
  case config.mode
21
- when :polling then RailsLiveReload::Rails::Middleware::Polling
22
- when :long_polling then RailsLiveReload::Rails::Middleware::LongPolling
34
+ when :polling then RailsLiveReload::Middleware::Polling
35
+ when :long_polling then RailsLiveReload::Middleware::LongPolling
36
+ when :websocket then RailsLiveReload::Middleware::WebSocket
23
37
  end
24
38
  end
25
39
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_live_reload
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Kasyanchuk
@@ -9,10 +9,10 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-05-31 00:00:00.000000000 Z
12
+ date: 2022-07-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
- name: rails
15
+ name: railties
16
16
  requirement: !ruby/object:Gem::Requirement
17
17
  requirements:
18
18
  - - ">="
@@ -40,41 +40,13 @@ dependencies:
40
40
  - !ruby/object:Gem::Version
41
41
  version: '0'
42
42
  - !ruby/object:Gem::Dependency
43
- name: puma
43
+ name: websocket-driver
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
48
  version: '0'
49
- type: :development
50
- prerelease: false
51
- version_requirements: !ruby/object:Gem::Requirement
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0'
56
- - !ruby/object:Gem::Dependency
57
- name: pry
58
- requirement: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: '0'
63
- type: :development
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - ">="
68
- - !ruby/object:Gem::Version
69
- version: '0'
70
- - !ruby/object:Gem::Dependency
71
- name: pry-nav
72
- requirement: !ruby/object:Gem::Requirement
73
- requirements:
74
- - - ">="
75
- - !ruby/object:Gem::Version
76
- version: '0'
77
- type: :development
49
+ type: :runtime
78
50
  prerelease: false
79
51
  version_requirements: !ruby/object:Gem::Requirement
80
52
  requirements:
@@ -82,13 +54,13 @@ dependencies:
82
54
  - !ruby/object:Gem::Version
83
55
  version: '0'
84
56
  - !ruby/object:Gem::Dependency
85
- name: sprockets-rails
57
+ name: nio4r
86
58
  requirement: !ruby/object:Gem::Requirement
87
59
  requirements:
88
60
  - - ">="
89
61
  - !ruby/object:Gem::Version
90
62
  version: '0'
91
- type: :development
63
+ type: :runtime
92
64
  prerelease: false
93
65
  version_requirements: !ruby/object:Gem::Requirement
94
66
  requirements:
@@ -123,17 +95,26 @@ files:
123
95
  - Rakefile
124
96
  - lib/rails_live_reload.rb
125
97
  - lib/rails_live_reload/checker.rb
126
- - lib/rails_live_reload/client.rb
127
98
  - lib/rails_live_reload/command.rb
128
99
  - lib/rails_live_reload/config.rb
129
100
  - lib/rails_live_reload/engine.rb
130
101
  - lib/rails_live_reload/instrument/metrics_collector.rb
131
- - lib/rails_live_reload/rails/middleware/base.rb
132
- - lib/rails_live_reload/rails/middleware/long_polling.rb
133
- - lib/rails_live_reload/rails/middleware/polling.rb
102
+ - lib/rails_live_reload/javascript/long_polling.js
103
+ - lib/rails_live_reload/javascript/polling.js
104
+ - lib/rails_live_reload/javascript/websocket.js
105
+ - lib/rails_live_reload/middleware/base.rb
106
+ - lib/rails_live_reload/middleware/long_polling.rb
107
+ - lib/rails_live_reload/middleware/polling.rb
108
+ - lib/rails_live_reload/middleware/web_socket.rb
134
109
  - lib/rails_live_reload/thread/current_request.rb
135
110
  - lib/rails_live_reload/version.rb
136
111
  - lib/rails_live_reload/watcher.rb
112
+ - lib/rails_live_reload/web_socket/base.rb
113
+ - lib/rails_live_reload/web_socket/client_socket.rb
114
+ - lib/rails_live_reload/web_socket/event_loop.rb
115
+ - lib/rails_live_reload/web_socket/message_buffer.rb
116
+ - lib/rails_live_reload/web_socket/stream.rb
117
+ - lib/rails_live_reload/web_socket/wrapper.rb
137
118
  homepage: https://github.com/railsjazz/rails_live_reload
138
119
  licenses:
139
120
  - MIT
@@ -1,75 +0,0 @@
1
- module RailsLiveReload
2
- module Client
3
-
4
- def Client.long_polling_js
5
- <<~HTML.html_safe
6
- <script>
7
- (function() {
8
- const files = #{CurrentRequest.current.data.to_a.to_json};
9
- let retries_count = 0;
10
- function poll() {
11
- const formData = new FormData();
12
- formData.append('dt', #{Time.now.to_i})
13
- formData.append('files', JSON.stringify(files))
14
-
15
- fetch(
16
- "#{RailsLiveReload.config.url}",
17
- {
18
- method: "post",
19
- headers: { 'Accept': 'application/json', },
20
- body: formData
21
- }
22
- )
23
- .then(response => response.json())
24
- .then(data => {
25
- retries_count = 0;
26
- if(data['command'] === 'RELOAD') {
27
- window.location.reload();
28
- } else {
29
- poll();
30
- }
31
- }).catch(() => {
32
- retries_count++;
33
-
34
- if(retries_count < 10) {
35
- setTimeout(poll, 5000)
36
- }
37
- })
38
- }
39
- poll();
40
- })();
41
- </script>
42
- HTML
43
- end
44
-
45
- def Client.polling_js
46
- <<~HTML.html_safe
47
- <script>
48
- const files = #{CurrentRequest.current.data.to_a.to_json};
49
- const timer = setInterval(
50
- () => {
51
- const formData = new FormData();
52
- formData.append('dt', #{Time.now.to_i})
53
- formData.append('files', JSON.stringify(files))
54
- fetch(
55
- "#{RailsLiveReload.config.url}",
56
- {
57
- method: "post",
58
- headers: { 'Accept': 'application/json', },
59
- body: formData
60
- }
61
- )
62
- .then(response => response.json())
63
- .then(data => {
64
- if(data['command'] === 'RELOAD') {
65
- clearInterval(timer);
66
- window.location.reload();
67
- }
68
- })
69
- }, #{RailsLiveReload.config.polling_interval}
70
- )
71
- </script>
72
- HTML
73
- end
74
- end
75
- end
@@ -1,63 +0,0 @@
1
- module RailsLiveReload
2
- module Rails
3
- module Middleware
4
- class Base
5
- def initialize(app)
6
- @app = app
7
- end
8
-
9
- def call(env)
10
- dup.call!(env)
11
- end
12
-
13
- def call!(env)
14
- request = Rack::Request.new(env)
15
-
16
- if env["REQUEST_PATH"] == RailsLiveReload.config.url
17
- rails_live_response(request)
18
- else
19
- @status, @headers, @response = @app.call(env)
20
-
21
- if html? && @response.respond_to?(:[]) && (@status == 500 || (@status.to_s =~ /20./ && request.get?))
22
- new_response = make_new_response(@response[0])
23
- @headers['Content-Length'] = new_response.bytesize.to_s
24
- @response = [new_response]
25
- end
26
-
27
- [@status, @headers, @response]
28
- end
29
- rescue Exception => ex
30
- puts ex.message
31
- puts ex.backtrace.take(10)
32
- raise ex
33
- end
34
-
35
- private
36
-
37
- def rails_live_response(request)
38
- raise NotImplementedError
39
- end
40
-
41
- def client_javascript(request)
42
- raise NotImplementedError
43
- end
44
-
45
- def make_new_response(body)
46
- body.sub("</head>", "</head>#{client_javascript}")
47
- end
48
-
49
- def html?
50
- @headers["Content-Type"].to_s.include?("text/html")
51
- end
52
-
53
- def new_thread
54
- Thread.new {
55
- t2 = Thread.current
56
- t2.abort_on_exception = true
57
- yield
58
- }
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,51 +0,0 @@
1
- module RailsLiveReload
2
- module Rails
3
- module Middleware
4
- class LongPolling < Base
5
- private
6
-
7
- def rails_live_response(request)
8
- params = request.params
9
- body = lambda do |stream|
10
- new_thread do
11
- counter = 0
12
-
13
- loop do
14
- command = RailsLiveReload::Command.new(params)
15
-
16
- if command.reload?
17
- stream.write(command.payload.to_json) and break
18
- end
19
-
20
- sleep(RailsLiveReload.config.long_polling_sleep_duration)
21
- counter += 1
22
-
23
- stream.write(command.payload.to_json) and break if counter >= max_sleeps_count
24
- end
25
- ensure
26
- stream.close
27
- end
28
- end
29
-
30
- [ 200, { 'Content-Type' => 'application/json', 'rack.hijack' => body }, nil ]
31
- end
32
-
33
- def client_javascript
34
- RailsLiveReload::Client.long_polling_js
35
- end
36
-
37
- def max_sleeps_count
38
- RailsLiveReload.config.timeout * (1 / RailsLiveReload.config.long_polling_sleep_duration)
39
- end
40
-
41
- def new_thread
42
- Thread.new {
43
- t2 = Thread.current
44
- t2.abort_on_exception = true
45
- yield
46
- }
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,21 +0,0 @@
1
- module RailsLiveReload
2
- module Rails
3
- module Middleware
4
- class Polling < Base
5
- private
6
-
7
- def rails_live_response(request)
8
- [
9
- 200,
10
- { 'Content-Type' => 'application/json' },
11
- [ RailsLiveReload::Command.new(request.params).payload.to_json ]
12
- ]
13
- end
14
-
15
- def client_javascript
16
- RailsLiveReload::Client.polling_js
17
- end
18
- end
19
- end
20
- end
21
- end