ruflet 0.0.19 → 0.0.20

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.
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruflet_core"
4
+
5
+ module Ruflet
6
+ # Development-time hot reload for `ruflet run`.
7
+ #
8
+ # This module deliberately lives in the ruflet CLI gem: the ruby_runtime
9
+ # prebuilt VM compiles every file under ruflet_core/lib and ruflet_server/lib
10
+ # into self-contained production apps, so dev-only machinery must stay out of
11
+ # those gems. The framework already exposes the two hooks hot reload needs:
12
+ # Ruflet.with_run_interceptor to capture the app entrypoint block, and
13
+ # Ruflet::Server#reload_app! to repaint live sessions over their open
14
+ # WebSocket connections.
15
+ module HotReload
16
+ Error = Class.new(StandardError)
17
+
18
+ DEFAULT_POLL_INTERVAL = 0.25
19
+ EXCLUDED_DIRECTORIES = %w[
20
+ .git
21
+ .bundle
22
+ .dart_tool
23
+ .idea
24
+ .ruby-lsp
25
+ .vscode
26
+ build
27
+ coverage
28
+ log
29
+ node_modules
30
+ pkg
31
+ ruflet_client
32
+ tmp
33
+ vendor
34
+ ].freeze
35
+
36
+ def self.run(script:, watch_root: nil, poll_interval: DEFAULT_POLL_INTERVAL)
37
+ Runner.new(script: script, watch_root: watch_root, poll_interval: poll_interval).run
38
+ end
39
+
40
+ class Runner
41
+ attr_reader :server, :current_block, :reload_count
42
+
43
+ def initialize(script:, watch_root: nil, poll_interval: DEFAULT_POLL_INTERVAL, server_factory: nil, logger: nil)
44
+ @script = File.expand_path(script)
45
+ raise Error, "script not found: #{@script}" unless File.file?(@script)
46
+
47
+ root = watch_root.to_s.empty? ? File.dirname(@script) : watch_root
48
+ @watch_root = File.expand_path(root)
49
+ @poll_interval = poll_interval
50
+ @server_factory = server_factory
51
+ @logger = logger
52
+ @current_block = nil
53
+ @server = nil
54
+ @watcher = nil
55
+ @reload_count = 0
56
+ @force_reload = false
57
+ @reload_mutex = Mutex.new
58
+ end
59
+
60
+ def run
61
+ unless Ruflet.respond_to?(:with_run_interceptor)
62
+ log "installed ruflet_core does not support run interceptors; running without hot reload"
63
+ return load_script!
64
+ end
65
+
66
+ Ruflet.with_run_interceptor(run_interceptor) do
67
+ load_script!
68
+ raise Error, "#{@script} never called Ruflet.run" unless @server
69
+
70
+ @snapshot = file_snapshot
71
+ start_watcher
72
+ install_manual_reload_trap
73
+ @server.start
74
+ end
75
+ end
76
+
77
+ def stop
78
+ @watcher&.kill
79
+ @watcher = nil
80
+ end
81
+
82
+ def watching?
83
+ !@watcher.nil?
84
+ end
85
+
86
+ def request_reload
87
+ @force_reload = true
88
+ end
89
+
90
+ def reload!
91
+ @reload_mutex.synchronize do
92
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ begin
94
+ prune_watched_features!
95
+ load_script!
96
+ if @server.respond_to?(:reload_app!)
97
+ @server.reload_app!
98
+ else
99
+ log "installed ruflet_server does not support reload_app!; clients keep the previous UI"
100
+ end
101
+ @reload_count += 1
102
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
103
+ log "reloaded in #{elapsed_ms}ms"
104
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a broken script must never kill the dev loop
105
+ raise if fatal_error?(e)
106
+
107
+ log "reload failed: #{e.class}: #{e.message}"
108
+ Array(e.backtrace).first(3).each { |line| log " #{line}" }
109
+ end
110
+ end
111
+ end
112
+
113
+ def watched_files
114
+ files = Dir.glob(File.join(@watch_root, "**", "*.rb")).reject { |path| excluded_path?(path) }
115
+ files << @script unless files.include?(@script) || !File.file?(@script)
116
+ files
117
+ end
118
+
119
+ private
120
+
121
+ def run_interceptor
122
+ @run_interceptor ||= lambda do |entrypoint:, host:, port:|
123
+ @current_block = entrypoint
124
+ @server ||= create_server(host: host, port: port)
125
+ :ruflet_hot_reload
126
+ end
127
+ end
128
+
129
+ def create_server(host:, port:)
130
+ runner = self
131
+ factory = @server_factory || method(:default_server_factory)
132
+ factory.call(host: host, port: port) { |page| runner.current_block.call(page) }
133
+ end
134
+
135
+ def default_server_factory(host:, port:, &app_block)
136
+ require "ruflet_server"
137
+ Ruflet::Server.new(host: host, port: port, &app_block)
138
+ end
139
+
140
+ def load_script!
141
+ previous_verbose = $VERBOSE
142
+ $VERBOSE = nil
143
+ Kernel.load(@script)
144
+ ensure
145
+ $VERBOSE = previous_verbose
146
+ end
147
+
148
+ def prune_watched_features!
149
+ watched = {}
150
+ watched_files.each do |path|
151
+ watched[path] = true
152
+ begin
153
+ watched[File.realpath(path)] = true
154
+ rescue Errno::ENOENT
155
+ nil
156
+ end
157
+ end
158
+ $LOADED_FEATURES.reject! { |feature| watched.key?(feature) }
159
+ end
160
+
161
+ def excluded_path?(path)
162
+ relative = path.delete_prefix("#{@watch_root}#{File::SEPARATOR}")
163
+ return false if relative == path
164
+
165
+ relative.split(File::SEPARATOR).any? do |component|
166
+ component.start_with?(".") || EXCLUDED_DIRECTORIES.include?(component)
167
+ end
168
+ end
169
+
170
+ def file_snapshot
171
+ snapshot = {}
172
+ watched_files.each do |path|
173
+ begin
174
+ snapshot[path] = File.mtime(path).to_f
175
+ rescue Errno::ENOENT
176
+ next
177
+ end
178
+ end
179
+ snapshot
180
+ end
181
+
182
+ def start_watcher
183
+ runner = self
184
+ @watcher = Thread.new do
185
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
186
+ runner.send(:watch_loop)
187
+ end
188
+ end
189
+
190
+ def watch_loop
191
+ loop do
192
+ sleep @poll_interval
193
+ forced = @force_reload
194
+ current = file_snapshot
195
+ next unless forced || current != @snapshot
196
+
197
+ @force_reload = false
198
+ # Give editors a moment to finish multi-file saves.
199
+ sleep 0.05 unless forced
200
+ @snapshot = file_snapshot
201
+ reload!
202
+ end
203
+ end
204
+
205
+ def install_manual_reload_trap
206
+ return unless Signal.list.key?("USR1")
207
+
208
+ Signal.trap("USR1") { @force_reload = true }
209
+ rescue ArgumentError, NotImplementedError
210
+ nil
211
+ end
212
+
213
+ def fatal_error?(error)
214
+ error.is_a?(SystemExit) || error.is_a?(SignalException) || error.is_a?(NoMemoryError)
215
+ end
216
+
217
+ def log(message)
218
+ if @logger
219
+ @logger.call(message)
220
+ else
221
+ warn "[ruflet reload] #{message}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.19" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.20" unless const_defined?(:VERSION)
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.19
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -44,6 +44,8 @@ files:
44
44
  - lib/ruflet/cli/run_command.rb
45
45
  - lib/ruflet/cli/templates.rb
46
46
  - lib/ruflet/cli/update_command.rb
47
+ - lib/ruflet/hot_reload.rb
48
+ - lib/ruflet/hot_reload/harness.rb
47
49
  - lib/ruflet/version.rb
48
50
  - lib/ruflet_cli.rb
49
51
  homepage: https://github.com/AdamMusa/Ruflet