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.
- checksums.yaml +4 -4
- data/README.md +2 -3
- data/lib/ruflet/cli/build_command.rb +655 -107
- data/lib/ruflet/cli/flutter_sdk.rb +17 -3
- data/lib/ruflet/cli/new_command.rb +28 -21
- data/lib/ruflet/cli/run_command.rb +219 -56
- data/lib/ruflet/cli/templates.rb +8 -5
- data/lib/ruflet/cli.rb +1 -1
- data/lib/ruflet/hot_reload/harness.rb +32 -0
- data/lib/ruflet/hot_reload.rb +226 -0
- data/lib/ruflet/version.rb +1 -1
- metadata +3 -1
|
@@ -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
|
data/lib/ruflet/version.rb
CHANGED
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.
|
|
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
|