rails_live_reload 0.1.1 → 0.3.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: 98a01974fe3a99ed6546f43f777fc3a0e91d7f175179e561494740e95d3de9fe
4
- data.tar.gz: b22be442a32123b9e09b2649993b9dc865276fd4157ff4f34cdfbc4891fb7745
3
+ metadata.gz: 88846a7af50ff0f2941eeb26795f7156ef4c92b8397453d9810d0196a27851ba
4
+ data.tar.gz: 70337f1ce69e0fab74a7272633629a565f6106a86e00590e4eb0f2d49e71281e
5
5
  SHA512:
6
- metadata.gz: a9b46ace63dbac0a9854e44676556799ad58e3a97f0c188e99dfec77a07cc36f550c7767b281946e9331a71aa870c8494c81b45ae9a7ddbed663fd5ab9a447d8
7
- data.tar.gz: 1bb0e9c9bfe6c4d59e70ced62c591907f01ec058ef2733247cdc4be299d652898200e07aeff9cf9112a5df1cf18cc71579df637c9f7dc66a48631ed18458b71f
6
+ metadata.gz: 5c4a756e0c3cd05ae4e8d75c5d221d2bd18ec17bd36d70ad8b720244f849a058d3788ec401d5f53eb88e99d0cfe8c5f09afd8610acaaf60e194b162465eb6bcf
7
+ data.tar.gz: e23b0b4b83ed08589cd9a11066419e91fb58f4d3cf2dff4724de276408877b328829b96c598edaf77f9c90b251d8993486472bdde4a7d1bd88f5ed7c27c5e68c
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,25 +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.
46
-
47
43
  ### Create initializer `config/initializers/rails_live_reload.rb`:
48
44
 
49
45
 
50
46
  ```ruby
51
47
  RailsLiveReload.configure do |config|
52
- # config.url = "/rails/live/reload"
53
- # Available modes are: :long_polling (default) and :polling
54
- # config.mode = :long_polling
55
-
56
- # This is used with :long_polling mode
57
- # config.timeout = 30
58
- # config.long_polling_sleep_duration = 0.1
59
-
60
- # This is used with :polling mode
61
- # config.polling_interval = 100
48
+ # config.url = "/rails/live/reload"
62
49
 
63
50
  # Default watched folders & files
64
51
  # config.watch %r{app/views/.+\.(erb|haml|slim)$}
@@ -67,6 +54,8 @@ RailsLiveReload.configure do |config|
67
54
  # More examples:
68
55
  # config.watch %r{app/helpers/.+\.rb}, reload: :always
69
56
  # config.watch %r{config/locales/.+\.yml}, reload: :always
57
+
58
+ # config.enabled = Rails.env.development?
70
59
  end if defined?(RailsLiveReload)
71
60
  ```
72
61
 
@@ -76,7 +65,7 @@ There are 3 main parts:
76
65
 
77
66
  1) listener of file changes (using `listen` gem)
78
67
  2) collector of rendered views (see rails instrumentation)
79
- 3) middleware which is responding to polling JS calls
68
+ 3) JavaScript client that communicates with server and triggers reloading when needed
80
69
 
81
70
  ## Notes
82
71
 
@@ -89,15 +78,13 @@ You are welcome to contribute. See list of `TODO's` below.
89
78
  ## TODO
90
79
 
91
80
  - reload CSS without reloading the whole page?
92
- - add `:websocket` mode?
93
81
  - smarter reload if there is a change in helper (check methods from rendered views?)
94
82
  - generator for initializer
95
83
  - more complex rules? e.g. if "user.rb" file is changed - reload all pages with rendered "users" views
96
84
  - check with older Rails versions
97
85
  - tests or specs
98
86
  - CI (github actions)
99
- - improve how JS code is injected into HTML
100
- - improve work with turbo, turbolinks
87
+ - auto reload when rendered controller was changed
101
88
 
102
89
  ## License
103
90
 
@@ -1,11 +1,18 @@
1
1
  module RailsLiveReload
2
2
  class Checker
3
+ def self.files
4
+ @files
5
+ end
6
+
7
+ def self.files=(files)
8
+ @files = files
9
+ end
3
10
 
4
11
  def self.scan(dt, rendered_files)
5
12
  temp = []
6
13
 
7
14
  # all changed files
8
- RailsLiveReload.watcher.files.each do |file, fdt|
15
+ files.each do |file, fdt|
9
16
  temp << file if fdt && fdt > dt
10
17
  end
11
18
 
@@ -25,6 +32,5 @@ module RailsLiveReload
25
32
 
26
33
  result
27
34
  end
28
-
29
35
  end
30
- end
36
+ end
@@ -19,17 +19,13 @@ module RailsLiveReload
19
19
 
20
20
  class Config
21
21
  attr_reader :patterns
22
- attr_accessor :mode, :polling_interval, :timeout, :url, :watcher, :files, :enabled, :long_polling_sleep_duration
22
+ attr_accessor :url, :watcher, :files, :enabled
23
23
 
24
24
  def initialize
25
- @mode = :long_polling
26
- @timeout = 30
27
- @long_polling_sleep_duration = 0.1
28
- @polling_interval = 100
29
25
  @url = "/rails/live/reload"
30
26
  @watcher = nil
31
27
  @files = {}
32
- @enabled = ::Rails.env.development? && !defined?(Rails::Console)
28
+ @enabled = ::Rails.env.development?
33
29
 
34
30
  # These configs work for 95% apps, see README for more info
35
31
  @patterns = {
@@ -39,6 +35,10 @@ module RailsLiveReload
39
35
  @default_patterns_changed = false
40
36
  end
41
37
 
38
+ def root_path
39
+ @root_path ||= ::Rails.application.root
40
+ end
41
+
42
42
  def watch(pattern, reload: :on_change)
43
43
  unless @default_patterns_changed
44
44
  @default_patterns_changed = true
@@ -47,5 +47,9 @@ module RailsLiveReload
47
47
 
48
48
  patterns[pattern] = reload
49
49
  end
50
+
51
+ def socket_path
52
+ root_path.join('tmp/sockets/rails_live_reload.sock')
53
+ end
50
54
  end
51
55
  end
@@ -1,32 +1,41 @@
1
1
  module RailsLiveReload
2
2
  class Railtie < ::Rails::Engine
3
-
4
- if RailsLiveReload.enabled?
3
+ if RailsLiveReload.enabled? && defined?(::Rails::Server)
5
4
  initializer "rails_live_reload.middleware" do |app|
6
5
  if ::Rails::VERSION::MAJOR.to_i >= 5
7
- app.middleware.insert_after ActionDispatch::Executor, RailsLiveReload.middleware
6
+ app.middleware.insert_after ActionDispatch::Executor, RailsLiveReload::Middleware::Base
8
7
  else
9
8
  begin
10
- app.middleware.insert_after ActionDispatch::Static, RailsLiveReload.middleware
9
+ app.middleware.insert_after ActionDispatch::Static, RailsLiveReload::Middleware::Base
11
10
  rescue
12
- app.middleware.insert_after Rack::SendFile, RailsLiveReload.middleware
11
+ app.middleware.insert_after Rack::SendFile, RailsLiveReload::Middleware::Base
13
12
  end
14
13
  end
14
+ end
15
15
 
16
+ initializer "rails_live_reload.watcher" do
16
17
  RailsLiveReload::Watcher.init
17
18
  end
18
19
 
19
- initializer :configure_metrics, after: :initialize_logger do
20
+ initializer "rails_live_reload.configure_metrics", after: :initialize_logger do
20
21
  ActiveSupport::Notifications.subscribe(
21
22
  /\.action_view/,
22
23
  RailsLiveReload::Instrument::MetricsCollector.new
23
24
  )
24
25
  end
25
26
 
26
- initializer :reset_current_request, after: :initialize_logger do |app|
27
+ initializer "rails_live_reload.reset_current_request", after: :initialize_logger do |app|
27
28
  app.executor.to_run { CurrentRequest.cleanup }
28
29
  app.executor.to_complete { CurrentRequest.cleanup }
29
30
  end
31
+
32
+ initializer "rails_live_reload.routes" do
33
+ config.after_initialize do |app|
34
+ app.routes.prepend do
35
+ mount RailsLiveReload.server => RailsLiveReload.config.url, internal: true, anchor: true
36
+ end
37
+ end
38
+ end
30
39
  end
31
40
  end
32
41
  end
@@ -0,0 +1,6 @@
1
+ /*!
2
+ Rails Live Reload 0.3.0
3
+ Copyright © 2022 RailsJazz
4
+ https://railsjazz.com
5
+ */
6
+ var RailsLiveReload=function(){"use strict";const t="RELOAD",e=["rails-live-reload-v1-json"];class n{static _instance;static get instance(){return n._instance||(n._instance=new this),n._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}=JSON.parse(this.optionsNode.textContent);this.files=t,this.time=e,this.url=n}fullReload(){window.location.reload()}get optionsNode(){const t=document.getElementById("rails-live-reload-options");if(!t)throw"Unable to find RailsLiveReload options";return t}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),e),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",(()=>{n.start()})),n}();
@@ -0,0 +1,55 @@
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
+ if env["REQUEST_PATH"].starts_with?(RailsLiveReload.config.url)
14
+ ::Rails.logger.silence do
15
+ @app.call(env)
16
+ end
17
+ else
18
+ request = Rack::Request.new(env)
19
+ status, headers, response = @app.call(env)
20
+
21
+ if html?(headers) && 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
+ end
30
+
31
+ private
32
+
33
+ def make_new_response(body)
34
+ body = body.sub("</head>", <<~HTML.html_safe)
35
+ <script defer type="text/javascript" src="#{RailsLiveReload.config.url}/script"></script>
36
+ </head>
37
+ HTML
38
+ body.sub("</body>", <<~HTML.html_safe)
39
+ <script id="rails-live-reload-options" type="application/json">
40
+ #{{
41
+ files: CurrentRequest.current.data.to_a,
42
+ time: Time.now.to_i,
43
+ url: RailsLiveReload.config.url
44
+ }.to_json}
45
+ </script>
46
+ </body>
47
+ HTML
48
+ end
49
+
50
+ def html?(headers)
51
+ headers["Content-Type"].to_s.include?("text/html")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,72 @@
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 Server
10
+ # This class is based on ActionCable
11
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/server/base.rb
12
+ class Base
13
+ include RailsLiveReload::Server::Connections
14
+
15
+ attr_reader :mutex
16
+
17
+ def reload_all
18
+ @mutex.synchronize do
19
+ connections.each(&:reload)
20
+ end
21
+ end
22
+
23
+ def initialize
24
+ @mutex = Monitor.new
25
+ @event_loop = nil
26
+ end
27
+
28
+ # Called by Rack to set up the server.
29
+ def call(env)
30
+ case env["REQUEST_PATH"]
31
+ when RailsLiveReload.config.url
32
+ setup_socket
33
+ setup_heartbeat_timer
34
+ request = Rack::Request.new(env)
35
+ RailsLiveReload::WebSocket::Base.new(self, request).process
36
+ when "#{RailsLiveReload.config.url}/script"
37
+ content = client_javascript
38
+ [200, {'Content-Type' => 'application/javascript', 'Content-Length' => content.size.to_s, 'Cache-Control' => 'no-store'}, [content]]
39
+ else
40
+ raise ActionController::RoutingError, 'Not found'
41
+ end
42
+ end
43
+
44
+ def client_javascript
45
+ @client_javascript || @mutex.synchronize { @client_javascript ||= File.open(File.join(File.dirname(__FILE__), "../javascript/websocket.js")).read }
46
+ end
47
+
48
+ def event_loop
49
+ @event_loop || @mutex.synchronize { @event_loop ||= RailsLiveReload::WebSocket::EventLoop.new }
50
+ end
51
+
52
+ def setup_socket
53
+ @socket ||= UNIXSocket.open(RailsLiveReload.config.socket_path).tap do |socket|
54
+ Thread.new do
55
+ loop do
56
+ data = JSON.parse socket.readline
57
+
58
+ case data["event"]
59
+ when RailsLiveReload::INTERNAL[:socket_events][:reload]
60
+ RailsLiveReload::Checker.files = data['files']
61
+
62
+ reload_all
63
+ else
64
+ raise NotImplementedError
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,27 @@
1
+ module RailsLiveReload
2
+ module Server
3
+ # This class is strongly based on ActionCable
4
+ # https://github.com/rails/rails/blob/v7.0.3/actioncable/lib/action_cable/server/connections.rb
5
+ module Connections
6
+ BEAT_INTERVAL = 3
7
+
8
+ def connections
9
+ @connections || @mutex.synchronize { @connections ||= Concurrent::Array.new }
10
+ end
11
+
12
+ def add_connection(connection)
13
+ connections << connection
14
+ end
15
+
16
+ def remove_connection(connection)
17
+ connections.delete connection
18
+ end
19
+
20
+ def setup_heartbeat_timer
21
+ @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do
22
+ event_loop.post { connections.each(&:beat) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsLiveReload
2
- VERSION = "0.1.1"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -1,6 +1,10 @@
1
1
  module RailsLiveReload
2
2
  class Watcher
3
- attr_reader :root, :files
3
+ attr_reader :files, :sockets
4
+
5
+ def root
6
+ RailsLiveReload.config.root_path
7
+ end
4
8
 
5
9
  def Watcher.init
6
10
  watcher = new
@@ -8,15 +12,16 @@ module RailsLiveReload
8
12
  end
9
13
 
10
14
  def initialize
11
- @root = ::Rails.application.root
12
15
  @files = {}
16
+ @sockets = []
13
17
 
14
18
  puts "Watching: #{root}"
15
19
  RailsLiveReload.patterns.each do |pattern, rule|
16
- puts " #{pattern} => #{rule}"
20
+ puts " #{pattern} => #{rule}"
17
21
  end
18
22
 
19
23
  build_tree
24
+ start_socket
20
25
  start_listener
21
26
  end
22
27
 
@@ -27,6 +32,7 @@ module RailsLiveReload
27
32
  all.each do |file|
28
33
  files[file] = File.mtime(file).to_i rescue nil
29
34
  end
35
+ reload_all
30
36
  end
31
37
  listener.start
32
38
  end
@@ -37,5 +43,35 @@ module RailsLiveReload
37
43
  files[file] = File.mtime(file).to_i rescue nil
38
44
  end
39
45
  end
46
+
47
+ def reload_all
48
+ data = {
49
+ event: RailsLiveReload::INTERNAL[:socket_events][:reload],
50
+ files: files
51
+ }.to_json
52
+
53
+ @sockets.each do |socket, _|
54
+ socket.puts data
55
+ end
56
+ end
57
+
58
+ def start_socket
59
+ Thread.new do
60
+ Socket.unix_server_socket(RailsLiveReload.config.socket_path.to_s) do |sock|
61
+ loop do
62
+ socket, _ = sock.accept
63
+ sockets << socket
64
+ Thread.new do
65
+ begin
66
+ socket.eof
67
+ ensure
68
+ socket.close
69
+ sockets.delete socket
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
40
76
  end
41
77
  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