├── .github
└── FUNDING.yml
├── .gitignore
├── CHANGES.md
├── Gemfile
├── Gemfile.lock
├── MIT-LICENSE
├── README.md
├── Rakefile
├── bin
└── test
├── docs
└── rails_live_reload.gif
├── javascript
├── index.js
├── package.json
└── rollup.config.js
├── lib
├── generators
│ ├── rails_live_reload
│ │ └── install_generator.rb
│ └── templates
│ │ └── rails_live_reload.rb
├── rails_live_reload.rb
└── rails_live_reload
│ ├── checker.rb
│ ├── command.rb
│ ├── config.rb
│ ├── engine.rb
│ ├── instrument
│ └── metrics_collector.rb
│ ├── javascript
│ └── websocket.js
│ ├── middleware
│ └── base.rb
│ ├── server
│ ├── base.rb
│ └── connections.rb
│ ├── thread
│ └── current_request.rb
│ ├── version.rb
│ ├── watcher.rb
│ └── web_socket
│ ├── base.rb
│ ├── client_socket.rb
│ ├── event_loop.rb
│ ├── message_buffer.rb
│ ├── stream.rb
│ └── wrapper.rb
├── rails_live_reload.gemspec
└── test
├── dummy
├── Rakefile
├── app
│ ├── assets
│ │ ├── config
│ │ │ └── manifest.js
│ │ ├── images
│ │ │ └── .keep
│ │ ├── javascripts
│ │ │ └── .keep
│ │ └── stylesheets
│ │ │ └── application.css
│ ├── channels
│ │ └── application_cable
│ │ │ ├── channel.rb
│ │ │ └── connection.rb
│ ├── controllers
│ │ ├── application_controller.rb
│ │ ├── concerns
│ │ │ └── .keep
│ │ ├── home_controller.rb
│ │ └── users_controller.rb
│ ├── helpers
│ │ └── application_helper.rb
│ ├── jobs
│ │ └── application_job.rb
│ ├── mailers
│ │ └── application_mailer.rb
│ ├── models
│ │ ├── application_record.rb
│ │ ├── concerns
│ │ │ └── .keep
│ │ └── user.rb
│ └── views
│ │ ├── home
│ │ ├── _info.html.erb
│ │ ├── _shared.html.erb
│ │ ├── about.html.erb
│ │ └── index.html.erb
│ │ ├── layouts
│ │ ├── application.html.erb
│ │ ├── mailer.html.erb
│ │ └── mailer.text.erb
│ │ └── users
│ │ ├── _form.html.erb
│ │ ├── _user.html.erb
│ │ ├── edit.html.erb
│ │ ├── index.html.erb
│ │ ├── new.html.erb
│ │ └── show.html.erb
├── bin
│ ├── rails
│ ├── rake
│ └── setup
├── config.ru
├── config
│ ├── application.rb
│ ├── boot.rb
│ ├── cable.yml
│ ├── database.yml
│ ├── environment.rb
│ ├── environments
│ │ ├── development.rb
│ │ ├── production.rb
│ │ └── test.rb
│ ├── initializers
│ │ ├── content_security_policy.rb
│ │ ├── filter_parameter_logging.rb
│ │ ├── inflections.rb
│ │ ├── permissions_policy.rb
│ │ └── rails_live_reload.rb
│ ├── locales
│ │ └── en.yml
│ ├── puma.rb
│ ├── routes.rb
│ └── storage.yml
├── db
│ ├── migrate
│ │ └── 20220528195146_create_users.rb
│ └── schema.rb
├── lib
│ └── assets
│ │ └── .keep
├── log
│ └── .keep
└── public
│ ├── 404.html
│ ├── 422.html
│ ├── 500.html
│ ├── apple-touch-icon-precomposed.png
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── rails_live_reload.rb
└── test_helper.rb
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | patreon: igorkasyanchuk
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /doc/
3 | /log/*.log
4 | /pkg/
5 | /tmp/
6 | /test/dummy/db/*.sqlite3
7 | /test/dummy/db/*.sqlite3-*
8 | /test/dummy/log/*.log
9 | /test/dummy/storage/
10 | /test/dummy/tmp/
11 |
12 | *.gem
13 |
14 | javascript/node_modules
15 | javascript/yarn.lock
16 |
17 | .DS_Store
18 |
19 | **/*/.DS_Store
20 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | ## 0.5.0
2 |
3 | - Ensure reload works even when the socket directory does not exists https://github.com/railsjazz/rails_live_reload/pull/43
4 | - Add an option to ignore unnecessary directories such as node_modules/ https://github.com/railsjazz/rails_live_reload/pull/42
5 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | gemspec
5 |
6 | gem "rails", "~> 7.1.0"
7 |
8 | gem "puma"
9 | gem "sprockets-rails"
10 |
11 | gem "pry"
12 | gem "pry-nav"
13 |
14 | gem "sqlite3"
15 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | rails_live_reload (0.5.0)
5 | listen
6 | nio4r
7 | railties
8 | websocket-driver
9 |
10 | GEM
11 | remote: https://rubygems.org/
12 | specs:
13 | actioncable (7.1.5.1)
14 | actionpack (= 7.1.5.1)
15 | activesupport (= 7.1.5.1)
16 | nio4r (~> 2.0)
17 | websocket-driver (>= 0.6.1)
18 | zeitwerk (~> 2.6)
19 | actionmailbox (7.1.5.1)
20 | actionpack (= 7.1.5.1)
21 | activejob (= 7.1.5.1)
22 | activerecord (= 7.1.5.1)
23 | activestorage (= 7.1.5.1)
24 | activesupport (= 7.1.5.1)
25 | mail (>= 2.7.1)
26 | net-imap
27 | net-pop
28 | net-smtp
29 | actionmailer (7.1.5.1)
30 | actionpack (= 7.1.5.1)
31 | actionview (= 7.1.5.1)
32 | activejob (= 7.1.5.1)
33 | activesupport (= 7.1.5.1)
34 | mail (~> 2.5, >= 2.5.4)
35 | net-imap
36 | net-pop
37 | net-smtp
38 | rails-dom-testing (~> 2.2)
39 | actionpack (7.1.5.1)
40 | actionview (= 7.1.5.1)
41 | activesupport (= 7.1.5.1)
42 | nokogiri (>= 1.8.5)
43 | racc
44 | rack (>= 2.2.4)
45 | rack-session (>= 1.0.1)
46 | rack-test (>= 0.6.3)
47 | rails-dom-testing (~> 2.2)
48 | rails-html-sanitizer (~> 1.6)
49 | actiontext (7.1.5.1)
50 | actionpack (= 7.1.5.1)
51 | activerecord (= 7.1.5.1)
52 | activestorage (= 7.1.5.1)
53 | activesupport (= 7.1.5.1)
54 | globalid (>= 0.6.0)
55 | nokogiri (>= 1.8.5)
56 | actionview (7.1.5.1)
57 | activesupport (= 7.1.5.1)
58 | builder (~> 3.1)
59 | erubi (~> 1.11)
60 | rails-dom-testing (~> 2.2)
61 | rails-html-sanitizer (~> 1.6)
62 | activejob (7.1.5.1)
63 | activesupport (= 7.1.5.1)
64 | globalid (>= 0.3.6)
65 | activemodel (7.1.5.1)
66 | activesupport (= 7.1.5.1)
67 | activerecord (7.1.5.1)
68 | activemodel (= 7.1.5.1)
69 | activesupport (= 7.1.5.1)
70 | timeout (>= 0.4.0)
71 | activestorage (7.1.5.1)
72 | actionpack (= 7.1.5.1)
73 | activejob (= 7.1.5.1)
74 | activerecord (= 7.1.5.1)
75 | activesupport (= 7.1.5.1)
76 | marcel (~> 1.0)
77 | activesupport (7.1.5.1)
78 | base64
79 | benchmark (>= 0.3)
80 | bigdecimal
81 | concurrent-ruby (~> 1.0, >= 1.0.2)
82 | connection_pool (>= 2.2.5)
83 | drb
84 | i18n (>= 1.6, < 2)
85 | logger (>= 1.4.2)
86 | minitest (>= 5.1)
87 | mutex_m
88 | securerandom (>= 0.3)
89 | tzinfo (~> 2.0)
90 | base64 (0.2.0)
91 | benchmark (0.4.0)
92 | bigdecimal (3.1.8)
93 | builder (3.3.0)
94 | coderay (1.1.3)
95 | concurrent-ruby (1.3.4)
96 | connection_pool (2.4.1)
97 | crass (1.0.6)
98 | date (3.4.1)
99 | drb (2.2.1)
100 | erubi (1.13.1)
101 | ffi (1.16.3)
102 | globalid (1.2.1)
103 | activesupport (>= 6.1)
104 | i18n (1.14.6)
105 | concurrent-ruby (~> 1.0)
106 | io-console (0.8.0)
107 | irb (1.14.3)
108 | rdoc (>= 4.0.0)
109 | reline (>= 0.4.2)
110 | listen (3.8.0)
111 | rb-fsevent (~> 0.10, >= 0.10.3)
112 | rb-inotify (~> 0.9, >= 0.9.10)
113 | logger (1.6.4)
114 | loofah (2.23.1)
115 | crass (~> 1.0.2)
116 | nokogiri (>= 1.12.0)
117 | mail (2.8.1)
118 | mini_mime (>= 0.1.1)
119 | net-imap
120 | net-pop
121 | net-smtp
122 | marcel (1.0.4)
123 | method_source (1.1.0)
124 | mini_mime (1.1.5)
125 | minitest (5.25.4)
126 | mutex_m (0.3.0)
127 | net-imap (0.5.2)
128 | date
129 | net-protocol
130 | net-pop (0.1.2)
131 | net-protocol
132 | net-protocol (0.2.2)
133 | timeout
134 | net-smtp (0.5.0)
135 | net-protocol
136 | nio4r (2.7.4)
137 | nokogiri (1.18.8-arm64-darwin)
138 | racc (~> 1.4)
139 | nokogiri (1.18.8-x86_64-darwin)
140 | racc (~> 1.4)
141 | nokogiri (1.18.8-x86_64-linux-gnu)
142 | racc (~> 1.4)
143 | pry (0.14.1)
144 | coderay (~> 1.1)
145 | method_source (~> 1.0)
146 | pry-nav (1.0.0)
147 | pry (>= 0.9.10, < 0.15)
148 | psych (5.2.2)
149 | date
150 | stringio
151 | puma (5.6.4)
152 | nio4r (~> 2.0)
153 | racc (1.8.1)
154 | rack (2.2.10)
155 | rack-session (1.0.2)
156 | rack (< 3)
157 | rack-test (2.1.0)
158 | rack (>= 1.3)
159 | rackup (1.0.1)
160 | rack (< 3)
161 | webrick
162 | rails (7.1.5.1)
163 | actioncable (= 7.1.5.1)
164 | actionmailbox (= 7.1.5.1)
165 | actionmailer (= 7.1.5.1)
166 | actionpack (= 7.1.5.1)
167 | actiontext (= 7.1.5.1)
168 | actionview (= 7.1.5.1)
169 | activejob (= 7.1.5.1)
170 | activemodel (= 7.1.5.1)
171 | activerecord (= 7.1.5.1)
172 | activestorage (= 7.1.5.1)
173 | activesupport (= 7.1.5.1)
174 | bundler (>= 1.15.0)
175 | railties (= 7.1.5.1)
176 | rails-dom-testing (2.2.0)
177 | activesupport (>= 5.0.0)
178 | minitest
179 | nokogiri (>= 1.6)
180 | rails-html-sanitizer (1.6.2)
181 | loofah (~> 2.21)
182 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
183 | railties (7.1.5.1)
184 | actionpack (= 7.1.5.1)
185 | activesupport (= 7.1.5.1)
186 | irb
187 | rackup (>= 1.0.0)
188 | rake (>= 12.2)
189 | thor (~> 1.0, >= 1.2.2)
190 | zeitwerk (~> 2.6)
191 | rake (13.2.1)
192 | rb-fsevent (0.11.2)
193 | rb-inotify (0.10.1)
194 | ffi (~> 1.0)
195 | rdoc (6.10.0)
196 | psych (>= 4.0.0)
197 | reline (0.6.0)
198 | io-console (~> 0.5)
199 | securerandom (0.4.1)
200 | sprockets (4.1.1)
201 | concurrent-ruby (~> 1.0)
202 | rack (> 1, < 3)
203 | sprockets-rails (3.4.2)
204 | actionpack (>= 5.2)
205 | activesupport (>= 5.2)
206 | sprockets (>= 3.0.0)
207 | sqlite3 (2.6.0-arm64-darwin)
208 | sqlite3 (2.6.0-x86_64-darwin)
209 | sqlite3 (2.6.0-x86_64-linux-gnu)
210 | stringio (3.1.2)
211 | thor (1.3.2)
212 | timeout (0.4.3)
213 | tzinfo (2.0.6)
214 | concurrent-ruby (~> 1.0)
215 | webrick (1.9.1)
216 | websocket-driver (0.7.6)
217 | websocket-extensions (>= 0.1.0)
218 | websocket-extensions (0.1.5)
219 | wrapped_print (0.1.0)
220 | activesupport
221 | zeitwerk (2.7.1)
222 |
223 | PLATFORMS
224 | arm64-darwin-21
225 | arm64-darwin-22
226 | arm64-darwin-23
227 | arm64-darwin-24
228 | x86_64-darwin-21
229 | x86_64-darwin-24
230 | x86_64-linux
231 |
232 | DEPENDENCIES
233 | pry
234 | pry-nav
235 | puma
236 | rails (~> 7.1.0)
237 | rails_live_reload!
238 | sprockets-rails
239 | sqlite3
240 | wrapped_print
241 |
242 | BUNDLED WITH
243 | 2.5.22
244 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rails Live Reload
2 |
3 | [](https://www.railsjazz.com)
4 |
5 | [](https://buymeacoffee.com/igorkasyanchuk)
6 |
7 | 
8 |
9 | This is the simplest and probably the most robust way to add live reloading to your Rails app.
10 |
11 | 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**.
12 |
13 | Works with:
14 |
15 | - views (EBR/HAML/SLIM) (the page is reloaded only when changed views which were rendered on the page)
16 | - partials
17 | - CSS/JS
18 | - helpers (if configured)
19 | - YAML locales (if configured)
20 | - on the "crash" page, so it will be reloaded as soon as you make a fix
21 |
22 | The page is reloaded fully with `window.location.reload()` to make sure that every chage will be displayed.
23 |
24 | ## Usage
25 |
26 | Just add this gem to the Gemfile (in development environment) and start the `rails s`.
27 |
28 | ## Installation
29 |
30 | Add this line to your application's Gemfile:
31 |
32 | ```ruby
33 | group :development do
34 | gem "rails_live_reload"
35 | end
36 | ```
37 |
38 | And then execute:
39 | ```bash
40 | $ bundle
41 | ```
42 |
43 | ## Configuration
44 |
45 | run command:
46 |
47 |
48 | ```bash
49 | rails generate rails_live_reload:install
50 | ```
51 | The generator will install an initializer which describes `RailsLiveReload` configuration options.
52 |
53 |
54 | ## How it works
55 |
56 | There are 3 main parts:
57 |
58 | 1) listener of file changes (using `listen` gem)
59 | 2) collector of rendered views (see rails instrumentation)
60 | 3) JavaScript client that communicates with server and triggers reloading when needed
61 |
62 | ## Notes
63 |
64 | The default configuration assumes that you either use asset pipeline, or that your assets compile quickly (on most applications asset compilation takes around 50-200ms), so it watches for changes in `app/assets` and `app/javascript` folders, this will not be a problem for 99% of users, but in case your asset compilation takes couple of seconds, this might not work propperly, in that case we would recommend you to add configuration to watch output folder.
65 |
66 | ## Contributing
67 |
68 | You are welcome to contribute. See list of `TODO's` below.
69 |
70 | ## TODO
71 |
72 | - reload CSS without reloading the whole page?
73 | - smarter reload if there is a change in helper (check methods from rendered views?)
74 | - generator for initializer
75 | - more complex rules? e.g. if "user.rb" file is changed - reload all pages with rendered "users" views
76 | - check with older Rails versions
77 | - tests or specs
78 | - CI (github actions)
79 | - auto reload when rendered controller was changed
80 |
81 | ## Troubleshooting
82 |
83 | - `Too many open files - pipe` - increase limits by `ulimit -n 10000`
84 |
85 | ## License
86 |
87 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
88 |
89 | [
](https://www.railsjazz.com/?utm_source=github&utm_medium=bottom&utm_campaign=rails_live_reload)
91 |
92 |
93 | [](https://buymeacoffee.com/igorkasyanchuk)
94 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 |
3 | require "bundler/gem_tasks"
4 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | $: << File.expand_path("../test", __dir__)
3 |
4 | require "bundler/setup"
5 | require "rails/plugin/test"
6 |
--------------------------------------------------------------------------------
/docs/rails_live_reload.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/railsjazz/rails_live_reload/b0dc93355a31a09f7bd0bcaeba46d81b034b1138/docs/rails_live_reload.gif
--------------------------------------------------------------------------------
/javascript/index.js:
--------------------------------------------------------------------------------
1 | const COMMANDS = {
2 | RELOAD: "RELOAD",
3 | };
4 |
5 | const PROTOCOLS = ['rails-live-reload-v1-json'];
6 |
7 | // This was copied from actioncable
8 | // https://github.com/rails/rails/blob/31b1403919eec0973921ab42251bfbbf3d4422b6/actioncable/app/javascript/action_cable/consumer.js#L60
9 | function createWebSocketURL(url) {
10 | if (typeof url === "function") {
11 | url = url();
12 | }
13 |
14 | if (url && !/^wss?:/i.test(url)) {
15 | const a = document.createElement("a");
16 | a.href = url;
17 | // Fix populating Location properties in IE. Otherwise, protocol will be blank.
18 | a.href = a.href;
19 | a.protocol = a.protocol.replace("http", "ws");
20 | return a.href;
21 | } else {
22 | return url;
23 | }
24 | }
25 |
26 | export default class RailsLiveReload {
27 | static _instance;
28 |
29 | static get instance() {
30 | if (RailsLiveReload._instance) return RailsLiveReload._instance;
31 |
32 | RailsLiveReload._instance = new this();
33 | return RailsLiveReload._instance;
34 | }
35 |
36 | static start() {
37 | this.instance.start();
38 | }
39 |
40 | constructor() {
41 | this.initialize();
42 |
43 | document.addEventListener("turbo:render", () => {
44 | if (document.documentElement.hasAttribute("data-turbo-preview")) return;
45 |
46 | this.restart();
47 | });
48 | document.addEventListener("turbolinks:render", () => {
49 | if (document.documentElement.hasAttribute("data-turbolinks-preview"))
50 | return;
51 |
52 | this.restart();
53 | });
54 | }
55 |
56 | initialize() {
57 | const { files, time, url } = JSON.parse(this.optionsNode.textContent);
58 |
59 | this.files = files;
60 | this.time = time;
61 | this.url = url;
62 | }
63 |
64 | fullReload() {
65 | window.location.reload();
66 | }
67 |
68 | get optionsNode() {
69 | const node = document.getElementById("rails-live-reload-options");
70 | if (!node) throw "Unable to find RailsLiveReload options";
71 |
72 | return node;
73 | }
74 |
75 | start() {
76 | if (this.connection) return;
77 |
78 | this.connection = new WebSocket(createWebSocketURL(this.url), PROTOCOLS);
79 | this.connection.onmessage = this.handleMessage.bind(this);
80 | this.connection.onopen = this.handleConnectionOpen.bind(this);
81 | this.connection.onclose = this.handleConnectionClosed.bind(this);
82 | }
83 |
84 | stop() {
85 | this.connection.close();
86 | }
87 |
88 | restart() {
89 | this.initialize();
90 | this.setupConnection();
91 | }
92 |
93 | setupConnection() {
94 | this.connection.send(
95 | JSON.stringify({
96 | event: "setup",
97 | options: {
98 | files: this.files,
99 | dt: this.time,
100 | },
101 | })
102 | );
103 | }
104 |
105 | handleConnectionOpen(e) {
106 | this.retriesCount = 0;
107 | this.setupConnection();
108 | }
109 |
110 | handleMessage(e) {
111 | const data = JSON.parse(e.data);
112 |
113 | if (data.command === COMMANDS.RELOAD) {
114 | this.fullReload();
115 | }
116 | }
117 |
118 | handleConnectionClosed(e) {
119 | this.connection = undefined;
120 | if (!e.wasClean && this.retriesCount <= 10) {
121 | this.retriesCount++;
122 | setTimeout(() => {
123 | this.start();
124 | }, 1000 * this.retriesCount);
125 | }
126 | }
127 | }
128 |
129 | document.addEventListener("DOMContentLoaded", () => {
130 | RailsLiveReload.start();
131 | });
132 |
--------------------------------------------------------------------------------
/javascript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rails_live_reload",
3 | "version": "0.3.6",
4 | "repository": "https://github.com/railsjazz/rails_live_reload.git",
5 | "author": "www.railsjazz.com",
6 | "license": "MIT",
7 | "scripts": {
8 | "build": "rollup -c",
9 | "watch": "rollup -wc"
10 | },
11 | "devDependencies": {
12 | "@rollup/plugin-node-resolve": "^13.3.0",
13 | "rollup": "^2.71.1",
14 | "rollup-plugin-terser": "^7.0.2"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/javascript/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from "@rollup/plugin-node-resolve";
2 | import { version } from "./package.json";
3 | import { terser } from "rollup-plugin-terser";
4 |
5 | const year = new Date().getFullYear();
6 |
7 | const banner = `/*!
8 | Rails Live Reload ${version}
9 | Copyright © ${year} RailsJazz
10 | https://railsjazz.com
11 | */
12 | `;
13 |
14 | export default [
15 | {
16 | input: "index.js",
17 | output: [
18 | {
19 | name: "RailsLiveReload",
20 | file: "../lib/rails_live_reload/javascript/websocket.js",
21 | format: "iife",
22 | banner: banner,
23 | plugins: [terser()],
24 | },
25 | ],
26 | plugins: [resolve()],
27 | watch: {
28 | include: "**",
29 | },
30 | },
31 | ];
32 |
--------------------------------------------------------------------------------
/lib/generators/rails_live_reload/install_generator.rb:
--------------------------------------------------------------------------------
1 | require 'rails/generators'
2 |
3 | module RailsLiveReload
4 | class InstallGenerator < Rails::Generators::Base
5 | source_root File.expand_path("../../templates", __FILE__)
6 |
7 | def copy_initializer
8 | template "rails_live_reload.rb", "config/initializers/rails_live_reload.rb"
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/generators/templates/rails_live_reload.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RailsLiveReload.configure do |config|
4 | # config.url = "/rails/live/reload"
5 |
6 | # Default watched folders & files
7 | # config.watch %r{app/views/.+\.(erb|haml|slim)$}
8 | # config.watch %r{(app|vendor)/(assets|javascript)/\w+/(.+\.(css|js|html|png|jpg|ts|jsx)).*}, reload: :always
9 |
10 | # More examples:
11 | # config.watch %r{app/helpers/.+\.rb}, reload: :always
12 | # config.watch %r{config/locales/.+\.yml}, reload: :always
13 |
14 | # Ignored folders & files
15 | # config.ignore %r{node_modules/}
16 |
17 | # config.enabled = Rails.env.development?
18 | end if defined?(RailsLiveReload)
19 |
--------------------------------------------------------------------------------
/lib/rails_live_reload.rb:
--------------------------------------------------------------------------------
1 | require "listen"
2 | require "rails_live_reload/version"
3 | require "rails_live_reload/config"
4 | require "rails_live_reload/watcher"
5 | require "rails_live_reload/server/connections"
6 | require "rails_live_reload/server/base"
7 | require "rails_live_reload/middleware/base"
8 | require "rails_live_reload/instrument/metrics_collector"
9 | require "rails_live_reload/thread/current_request"
10 | require "rails_live_reload/checker"
11 | require "rails_live_reload/command"
12 | require "rails_live_reload/engine"
13 |
14 | module RailsLiveReload
15 | mattr_accessor :watcher
16 | @@watcher = {}
17 |
18 | INTERNAL = {
19 | message_types: {
20 | welcome: "welcome",
21 | disconnect: "disconnect",
22 | ping: "ping",
23 | },
24 | disconnect_reasons: {
25 | invalid_request: "invalid_request",
26 | remote: "remote"
27 | },
28 | socket_events: {
29 | reload: 'reload'
30 | },
31 | protocols: ["rails-live-reload-v1-json"].freeze
32 | }
33 |
34 | module_function def server
35 | @server ||= RailsLiveReload::Server::Base.new
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/checker.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | class Checker
3 | def self.files
4 | @files
5 | end
6 |
7 | def self.files=(files)
8 | @files = files
9 | end
10 |
11 | def self.scan(dt, rendered_files)
12 | temp = []
13 |
14 | # all changed files
15 | files.each do |file, fdt|
16 | temp << file if fdt && fdt > dt
17 | end
18 |
19 | result = []
20 |
21 | temp.each do |file|
22 | RailsLiveReload.patterns.each do |pattern, rule|
23 | rule_1 = file.match(pattern) && rule == :always # Used for CSS, JS, yaml, helpers, etc.
24 | rule_2 = file.match(pattern) && rendered_files.include?(file) # Used to check if view was rendered
25 |
26 | if rule_1 || rule_2
27 | result << file
28 | break
29 | end
30 | end
31 | end
32 |
33 | result
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/command.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | class Command
3 | attr_reader :dt, :files
4 |
5 | def initialize(params)
6 | @dt = params["dt"].to_i
7 | @files = JSON.parse(params["files"]) rescue []
8 | end
9 |
10 | def changes
11 | RailsLiveReload::Checker.scan(dt, files)
12 | end
13 |
14 | def reload?
15 | !changes.size.zero?
16 | end
17 |
18 | def payload
19 | if reload?
20 | { command: "RELOAD" }
21 | else
22 | { command: "NO_CHANGES" }
23 | end
24 | end
25 |
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/config.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | class << self
3 | def configure
4 | yield config
5 | end
6 |
7 | def config
8 | @_config ||= Config.new
9 | end
10 |
11 | def patterns
12 | config.patterns
13 | end
14 |
15 | def ignore_patterns
16 | config.ignore_patterns
17 | end
18 |
19 | def enabled?
20 | config.enabled
21 | end
22 | end
23 |
24 | class Config
25 | attr_reader :patterns, :ignore_patterns
26 | attr_accessor :url, :watcher, :files, :enabled
27 |
28 | def initialize
29 | @url = "/rails/live/reload"
30 | @watcher = nil
31 | @files = {}
32 | @enabled = ::Rails.env.development?
33 |
34 | # These configs work for 95% apps, see README for more info
35 | @patterns = {
36 | %r{app/views/.+\.(erb|haml|slim)$} => :on_change,
37 | %r{(app|vendor)/(assets|javascript)/\w+/(.+\.(css|js|html|png|jpg|ts|jsx)).*} => :always
38 | }
39 | @default_patterns_changed = false
40 |
41 | @ignore_patterns = []
42 | end
43 |
44 | def root_path
45 | @root_path ||= ::Rails.application.root
46 | end
47 |
48 | def watch(pattern, reload: :on_change)
49 | unless @default_patterns_changed
50 | @default_patterns_changed = true
51 | @patterns = {}
52 | end
53 |
54 | patterns[pattern] = reload
55 | end
56 |
57 | def ignore(pattern)
58 | @ignore_patterns << pattern
59 | end
60 |
61 | def socket_path
62 | root_path.join('tmp/sockets/rails_live_reload.sock').then do |path|
63 | break path if path.to_s.size <= 104 # 104 is the max length of a socket path
64 |
65 | puts "Unable to create socket path inside the project, using /tmp instead"
66 | app_name = ::Rails.application.class.name.split('::').first.underscore
67 | Pathname.new("/tmp/rails_live_reload_#{app_name}.sock")
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/engine.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | class Railtie < ::Rails::Engine
3 | def enabled?
4 | RailsLiveReload.enabled? && (defined?(::Rails::Server) || ENV['SERVER_PROCESS'])
5 | end
6 |
7 | initializer "rails_live_reload.middleware" do |app|
8 | if enabled?
9 | if ::Rails::VERSION::MAJOR.to_i >= 5
10 | app.middleware.insert_after ActionDispatch::Executor, RailsLiveReload::Middleware::Base
11 | else
12 | begin
13 | app.middleware.insert_after ActionDispatch::Static, RailsLiveReload::Middleware::Base
14 | rescue
15 | app.middleware.insert_after Rack::SendFile, RailsLiveReload::Middleware::Base
16 | end
17 | end
18 | end
19 | end
20 |
21 | initializer "rails_live_reload.watcher" do
22 | if enabled?
23 | RailsLiveReload::Watcher.init
24 | end
25 | end
26 |
27 | initializer "rails_live_reload.configure_metrics", after: :initialize_logger do
28 | if enabled?
29 | ActiveSupport::Notifications.subscribe(
30 | /\.action_view/,
31 | RailsLiveReload::Instrument::MetricsCollector.new
32 | )
33 | end
34 | end
35 |
36 | initializer "rails_live_reload.reset_current_request", after: :initialize_logger do |app|
37 | if enabled?
38 | app.executor.to_run { CurrentRequest.cleanup }
39 | app.executor.to_complete { CurrentRequest.cleanup }
40 | end
41 | end
42 |
43 | initializer "rails_live_reload.routes" do
44 | config.after_initialize do |app|
45 | if enabled?
46 | app.routes.prepend do
47 | mount RailsLiveReload.server => RailsLiveReload.config.url, internal: true
48 | end
49 | end
50 | end
51 | end
52 | end
53 | end
--------------------------------------------------------------------------------
/lib/rails_live_reload/instrument/metrics_collector.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | module Instrument
3 | class MetricsCollector
4 | def call(event_name, started, finished, event_id, payload)
5 | CurrentRequest.current.data.add(payload[:identifier])
6 | end
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/javascript/websocket.js:
--------------------------------------------------------------------------------
1 | /*!
2 | Rails Live Reload 0.3.6
3 | Copyright © 2023 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}();
7 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/middleware/base.rb:
--------------------------------------------------------------------------------
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 = ActionDispatch::Request.new(env)
19 | status, headers, body = @app.call(env)
20 |
21 | if html?(headers) && (status == 500 || status == 422 || (status.to_s =~ /20./ && request.get?))
22 | return inject_rails_live_reload(request, status, headers, body)
23 | end
24 |
25 | [status, headers, body]
26 | end
27 | end
28 |
29 | private
30 |
31 | def inject_rails_live_reload(request, status, headers, body)
32 | response = Rack::Response.new([], status, headers)
33 |
34 | nonce = request&.content_security_policy_nonce
35 | if String === body
36 | response.write make_new_response(body, nonce)
37 | else
38 | body.each { |fragment| response.write make_new_response(fragment, nonce) }
39 | end
40 | body.close if body.respond_to?(:close)
41 | response.finish
42 | end
43 |
44 | def make_new_response(body, nonce)
45 | index = body.rindex(/<\/body>/i) || body.rindex(/<\/html>/i)
46 | return body if index.nil?
47 |
48 | body.insert(index, <<~HTML.html_safe)
49 |
50 |
57 | HTML
58 | end
59 |
60 | def html?(headers)
61 | headers["content-type"].to_s.include?("text/html")
62 | end
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/server/base.rb:
--------------------------------------------------------------------------------
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
73 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/server/connections.rb:
--------------------------------------------------------------------------------
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
28 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/thread/current_request.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | class CurrentRequest
3 | attr_accessor :data, :record
4 | attr_reader :request_id
5 |
6 | def CurrentRequest.init
7 | Thread.current[:rc_current_request] ||= CurrentRequest.new(SecureRandom.hex(16))
8 | end
9 |
10 | def CurrentRequest.current
11 | CurrentRequest.init
12 | end
13 |
14 | def CurrentRequest.cleanup
15 | Thread.current[:rc_current_request] = nil
16 | end
17 |
18 | def initialize(request_id)
19 | @request_id = request_id
20 | @data = Set.new
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/version.rb:
--------------------------------------------------------------------------------
1 | module RailsLiveReload
2 | VERSION = "0.5.0"
3 | end
4 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/watcher.rb:
--------------------------------------------------------------------------------
1 | require "fileutils"
2 |
3 | module RailsLiveReload
4 | class Watcher
5 | attr_reader :files, :sockets
6 |
7 | def root
8 | RailsLiveReload.config.root_path
9 | end
10 |
11 | def Watcher.init
12 | watcher = new
13 | RailsLiveReload.watcher = watcher
14 | end
15 |
16 | def initialize
17 | @files = {}
18 | @sockets = []
19 |
20 | puts "Watching: #{root}"
21 | RailsLiveReload.patterns.each do |pattern, rule|
22 | puts " #{pattern} => #{rule}"
23 | end
24 |
25 | build_tree
26 | create_socket_directory
27 | start_socket
28 | start_listener
29 | end
30 |
31 | def start_listener
32 | Thread.new do
33 | listener = Listen.to(root, ignore: RailsLiveReload.ignore_patterns) do |modified, added, removed|
34 | all = modified + added + removed
35 | all.each do |file|
36 | files[file] = File.mtime(file).to_i rescue nil
37 | end
38 | reload_all
39 | end
40 | listener.start
41 | end
42 | end
43 |
44 | def build_tree
45 | Dir.glob(File.join(root, '**', '*')).select{|file| File.file?(file)}.each do |file|
46 | files[file] = File.mtime(file).to_i rescue nil
47 | end
48 | end
49 |
50 | def reload_all
51 | data = {
52 | event: RailsLiveReload::INTERNAL[:socket_events][:reload],
53 | files: files
54 | }.to_json
55 |
56 | @sockets.each do |socket, _|
57 | socket.puts data
58 | end
59 | end
60 |
61 | def create_socket_directory
62 | FileUtils.mkdir_p File.dirname(RailsLiveReload.config.socket_path)
63 | end
64 |
65 | def start_socket
66 | Thread.new do
67 | Socket.unix_server_socket(RailsLiveReload.config.socket_path.to_s) do |sock|
68 | loop do
69 | socket, _ = sock.accept
70 | sockets << socket
71 | Thread.new do
72 | begin
73 | socket.eof
74 | ensure
75 | socket.close
76 | sockets.delete socket
77 | end
78 | end
79 | end
80 | end
81 | end
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/base.rb:
--------------------------------------------------------------------------------
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
117 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/client_socket.rb:
--------------------------------------------------------------------------------
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
154 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/event_loop.rb:
--------------------------------------------------------------------------------
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
132 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/message_buffer.rb:
--------------------------------------------------------------------------------
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
54 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/stream.rb:
--------------------------------------------------------------------------------
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
110 |
--------------------------------------------------------------------------------
/lib/rails_live_reload/web_socket/wrapper.rb:
--------------------------------------------------------------------------------
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
28 |
--------------------------------------------------------------------------------
/rails_live_reload.gemspec:
--------------------------------------------------------------------------------
1 | require_relative "lib/rails_live_reload/version"
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "rails_live_reload"
5 | spec.version = RailsLiveReload::VERSION
6 | spec.authors = ["Igor Kasyanchuk", "Liubomyr Manastyretskyi"]
7 | spec.email = ["igorkasyanchuk@gmail.com", "manastyretskyi@gmail.com"]
8 | spec.homepage = "https://github.com/railsjazz/rails_live_reload"
9 | spec.summary = "Ruby on Rails Live Reload"
10 | spec.description = "Ruby on Rails Live Reload with just a single line of code - just add the gem to Gemfile."
11 | spec.license = "MIT"
12 |
13 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
14 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
15 | end
16 |
17 | spec.add_dependency "railties"
18 | spec.add_dependency "listen"
19 | spec.add_dependency "websocket-driver"
20 | spec.add_dependency "nio4r"
21 |
22 | spec.add_development_dependency "wrapped_print"
23 | end
24 |
--------------------------------------------------------------------------------
/test/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../javascripts .js
3 | //= link_directory ../stylesheets .css
--------------------------------------------------------------------------------
/test/dummy/app/assets/images/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/railsjazz/rails_live_reload/b0dc93355a31a09f7bd0bcaeba46d81b034b1138/test/dummy/app/assets/images/.keep
--------------------------------------------------------------------------------
/test/dummy/app/assets/javascripts/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/railsjazz/rails_live_reload/b0dc93355a31a09f7bd0bcaeba46d81b034b1138/test/dummy/app/assets/javascripts/.keep
--------------------------------------------------------------------------------
/test/dummy/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /* Application styles */
2 |
3 | body {
4 | font-family: Consolas;
5 | background: linear-gradient(to top right, #e7bbbb, #9198e5);
6 | min-height: 100vh;
7 | background-repeat: no-repeat;
8 | padding: 140px;
9 | }
10 |
11 | .container {
12 | color: black;
13 | }
14 |
15 | ul {
16 | list-style-type: none;
17 | display: flex;
18 | margin: 0;
19 | padding: 0;
20 | font-size: 3rem;
21 | }
22 |
23 | ul li {
24 | margin-right: 5rem;
25 | }
26 |
27 | hr {
28 | margin: 1rem 0;
29 | border-color: rgb(36, 39, 43);
30 | border-width: 2px;
31 | }
32 |
33 | a {
34 | color: purple;
35 | padding: 0.5rem 1rem;
36 | }
37 |
38 | a:hover {
39 | background-color: purple;
40 | color: white;
41 | }
42 |
43 | button[type="submit"] {
44 | margin-top: 1rem;
45 | }
--------------------------------------------------------------------------------
/test/dummy/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | end
3 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/railsjazz/rails_live_reload/b0dc93355a31a09f7bd0bcaeba46d81b034b1138/test/dummy/app/controllers/concerns/.keep
--------------------------------------------------------------------------------
/test/dummy/app/controllers/home_controller.rb:
--------------------------------------------------------------------------------
1 | class HomeController < ApplicationController
2 | def index
3 | end
4 |
5 | def about
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/users_controller.rb:
--------------------------------------------------------------------------------
1 | class UsersController < ApplicationController
2 | before_action :set_user, only: %i[ show edit update destroy ]
3 |
4 | # GET /users
5 | def index
6 | @users = User.all
7 | end
8 |
9 | # GET /users/1
10 | def show
11 | end
12 |
13 | # GET /users/new
14 | def new
15 | @user = User.new
16 | end
17 |
18 | # GET /users/1/edit
19 | def edit
20 | end
21 |
22 | # POST /users
23 | def create
24 | @user = User.new(user_params)
25 |
26 | if @user.save
27 | redirect_to @user, notice: "User was successfully created."
28 | else
29 | render :new, status: :unprocessable_entity
30 | end
31 | end
32 |
33 | # PATCH/PUT /users/1
34 | def update
35 | if @user.update(user_params)
36 | redirect_to @user, notice: "User was successfully updated."
37 | else
38 | render :edit, status: :unprocessable_entity
39 | end
40 | end
41 |
42 | # DELETE /users/1
43 | def destroy
44 | @user.destroy
45 | redirect_to users_url, notice: "User was successfully destroyed."
46 | end
47 |
48 | private
49 | # Use callbacks to share common setup or constraints between actions.
50 | def set_user
51 | @user = User.find(params[:id])
52 | end
53 |
54 | # Only allow a list of trusted parameters through.
55 | def user_params
56 | params.require(:user).permit(:login, :age)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/dummy/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 |
3 | def make_it_uppercase(str)
4 | str.upcase
5 | end
6 |
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | # Automatically retry jobs that encountered a deadlock
3 | # retry_on ActiveRecord::Deadlocked
4 |
5 | # Most jobs are safe to ignore if the underlying records are no longer available
6 | # discard_on ActiveJob::DeserializationError
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: "from@example.com"
3 | layout "mailer"
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | primary_abstract_class
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/railsjazz/rails_live_reload/b0dc93355a31a09f7bd0bcaeba46d81b034b1138/test/dummy/app/models/concerns/.keep
--------------------------------------------------------------------------------
/test/dummy/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User < ApplicationRecord
2 | end
3 |
--------------------------------------------------------------------------------
/test/dummy/app/views/home/_info.html.erb:
--------------------------------------------------------------------------------
1 |
Find me in app/views/home/about.html.erb
3 | 4 | <%= "demo".upcase %> -------------------------------------------------------------------------------- /test/dummy/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 |Find me in app/views/home/index.html.erb
5 | 6 | <%= make_it_uppercase t('hello') %> 7 | 8 | <%= render 'info' %> 9 | <%= render 'shared' %> 10 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |4 | Login: 5 | <%= user.login %> 6 |
7 | 8 |9 | Age: 10 | <%= user.age %> 11 |
12 | 13 |<%= notice %>
2 | 3 |9 | <%= link_to "Show this user", user %> 10 |
11 | <% end %> 12 |<%= notice %>
2 | 3 | <%= render @user %> 4 | 5 |You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |If you are the application owner check the logs for more information.
64 |