rails_live_reload 0.1.2 → 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: 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