Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ end

gem "hanami-utils", github: "hanami/utils", branch: "main"
gem "hanami-cli", github: "hanami/cli", branch: "main"
gem "hanami", github: "hanami/hanami", branch: "main"
# Targets the in-place code reloading branch, which this gem depends on for
# `Hanami::Slice#reload!`. Restore to `branch: "main"` once that has landed.
gem "hanami", github: "hanami/hanami", branch: "internal-code-reloading"

gem "hanami-devtools", github: "hanami/devtools", branch: "main"

Expand Down
3 changes: 1 addition & 2 deletions hanami-reloader.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.3"

spec.add_runtime_dependency "guard", "~> 2.19"
spec.add_runtime_dependency "guard-puma", "~> 0.8"
spec.add_runtime_dependency "hanami-cli", "~> 3.0.0"
spec.add_runtime_dependency "rack", ">= 3.0"
spec.add_runtime_dependency "zeitwerk", "~> 2.6"
end

119 changes: 48 additions & 71 deletions lib/hanami/reloader/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,81 +5,71 @@
module Hanami
module Reloader
module Commands
# Guardfile
module Guardfile
def self.group
"server"
end

def self.default_path
path("Guardfile")
end

def self.path(value)
value
end
end

# Generate hanami-reloader configuration
# Removes configuration left behind by previous versions of hanami-reloader.
#
# Reloading no longer runs through Guard, so the `Guardfile` it used to generate is now
# dead weight. Nothing is generated in its place: the reloader is wired up by the `server`
# command below, with no per-app configuration.
#
# @api private
# @since 2.1.0
class Install < Hanami::CLI::Command
# @api private
# @since 2.1.0
#
# NOTE: Any change to this constant MUST be reflected in the `#generate_configuration` method,
# by copying and pasting this regex.
MATCHER = %r{^(app|config|lib|slices)([\\/][^\\/]+)*\.(rb|erb|haml|slim)$}i
# @since 3.1.0
GUARDFILE = "Guardfile"

desc "Generate configuration for code reloading"
desc "Remove obsolete code reloading configuration"

def initialize(fs: Dry::Files.new, **args)
super
end

def call(*, **)
generate_configuration(Guardfile.default_path)
end

private
return unless fs.exist?(GUARDFILE)
return unless fs.read(GUARDFILE).include?("guard \"puma\"")

def generate_configuration(path)
fs.write path, <<~CODE
# frozen_string_literal: true

group :#{Guardfile.group} do
guard "puma", port: ENV.fetch("#{Hanami::Port::ENV_VAR}", #{Hanami::Port::DEFAULT}), environment: ENV.fetch("HANAMI_ENV", "development") do
# Edit the following regular expression for your needs.
# See: https://hanakai.org/learn/hanami/app/code-reloading/
watch(%r{^(app|config|lib|slices)([\\/][^\\/]+)*.(rb|erb|haml|slim)$}i)
end
end
CODE
fs.delete(GUARDFILE)
out.puts "Removed #{GUARDFILE} (code reloading no longer uses Guard)"
end
end

# Override `hanami server` command
# Override `hanami server` to reload the app in place instead of restarting it.
#
# The app is built from `config.ru` here rather than by the Rack server, so that it can be
# wrapped in {Middleware} before being served. That keeps the reloader outside the app's own
# middleware stack, which a reload replaces, and means an app needs no `config.ru` changes to
# get reloading.
#
# @since 2.0.0
# @api private
class Server < Hanami::CLI::Commands::App::Server
# @since 2.0.0
# @api private
DEFAULT_GUARD_PUMA_OPTIONS = ["-n", "f", "-i", "-g", Guardfile.group, "-G"].freeze

# @since 2.0.0
# @api private
OPTIONS_SEPARATOR = " "

option :guardfile, type: :string, desc: "Path to Guardfile", default: Guardfile.default_path.to_s
option :code_reloading, type: :boolean, desc: "Code reloading", default: true
option :code_reloading, type: :boolean, desc: "Code reloading", default: true

desc "Start Hanami app server"

example [
"--no-code-reloading # Disable code reloading"
]

def call(**args)
code_reloading = args.fetch(:code_reloading)
def call(port: Hanami::Port::DEFAULT, **args)
return super(port: port, **args) unless code_reloading?(**args)

# Keeps HANAMI_PORT in step with an explicit `--port`, then resolves the port the same
# way the command we're replacing does, so a port set in `.env` is still honoured.
Hanami::Port.call!(port)

reloading_server.call(**args, port: Hanami::Port[port])
end

private

# @api private
# @since 3.1.0
def code_reloading?(**args)
return false unless args.fetch(:code_reloading)

if ENV["HANAMI_ENV"] == "production"
msg = <<~TEXT
err.puts <<~TEXT
WARNING: You are running `hanami server` in the production environment via hanami-reloader.

Code reloading is disabled, but `hanami server` and hanami-reloader are intended to be used in
Expand All @@ -88,29 +78,16 @@ def call(**args)
For production, start your web server directly, e.g. `bundle exec puma -C config/puma.rb`.
TEXT

err.puts msg

return super
return false
end

if code_reloading
guard_puma_env_vars!(**args)
exec "bundle exec guard #{guard_puma_options(**args)}"
else
super
end
true
end

private

def guard_puma_env_vars!(**args)
Hanami::Port.call!(args.fetch(:port))
end

def guard_puma_options(**args)
options = DEFAULT_GUARD_PUMA_OPTIONS.dup
options.push(Guardfile.path(args.fetch(:guardfile)))
options.join(OPTIONS_SEPARATOR)
# @api private
# @since 3.1.0
def reloading_server
Reloader::Server.new(out: out, err: err)
end
end
end
Expand Down
133 changes: 133 additions & 0 deletions lib/hanami/reloader/file_checker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true

module Hanami
module Reloader
# Detects changes to an app's source files by comparing their modification times.
#
# Nothing runs in the background: files are stat'd when {#updated?} is called, which the
# reloader does once per request. This needs no native extensions, behaves the same on every
# platform, and means a reload only ever happens between requests. Only the directories Hanami
# loads code from are walked, so the cost is proportional to the app rather than to the project
# (`node_modules/` and friends are never visited).
#
# @api private
# @since 3.1.0
class FileChecker
# Directories whose contents {Hanami::Slice#reload!} is able to pick up.
#
# @api private
# @since 3.1.0
WATCHED_DIRS = %w[app config lib slices].freeze

# @api private
# @since 3.1.0
WATCHED_EXTENSIONS = %w[rb erb haml slim].freeze

# Files that a reload cannot apply, because they are loaded once before the app exists.
#
# @api private
# @since 3.1.0
RESTART_REQUIRED_PATHS = [
File.join("config", "app.rb"),
"Gemfile",
"Gemfile.lock"
].freeze

# @api private
# @since 3.1.0
attr_reader :root

# @api private
# @since 3.1.0
def initialize(root:)
@root = Pathname(root)
@glob = File.join("**", "*.{#{WATCHED_EXTENSIONS.join(",")}}")
@signature = reloadable_signature
@restart_mtimes = restart_required_mtimes
end

# Returns true if any reloadable file has changed since the last {#commit!}.
#
# This deliberately does not record what it saw. Until {#commit!} is called the change is
# still considered outstanding, so a reload that raises will be attempted again on the next
# check rather than being swallowed.
#
# @return [Boolean]
#
# @api private
# @since 3.1.0
def updated?
reloadable_signature != @signature
end

# Accepts the current state of the files as the new baseline.
#
# @api private
# @since 3.1.0
def commit!
@signature = reloadable_signature
self
end

# Returns the paths of any changed files that a reload cannot apply.
#
# Unlike {#updated?} this records what it saw, so each change is reported once.
#
# @return [Array<String>] paths relative to the app root, empty if nothing changed
#
# @api private
# @since 3.1.0
def restart_required
current = restart_required_mtimes

changed = current.reject { |path, mtime| @restart_mtimes[path] == mtime }.keys
@restart_mtimes = current

changed
end

private

def reloadable_signature
paths = WATCHED_DIRS.flat_map { |dir| Dir.glob(root.join(dir, @glob)) }

# `config/app.rb` sits inside a watched directory but cannot be applied by a reload, so it
# is excluded here and reported by {#restart_required?} instead. Otherwise every edit to it
# would both warn and trigger a reload that changes nothing.
signature(paths - restart_required_paths)
end

def restart_required_paths
@restart_required_paths ||= RESTART_REQUIRED_PATHS.map { |path| root.join(path).to_s }
end

# Keyed by the relative path so a change can be reported by name, and tracked individually so
# that touching one file does not mask a change to another.
def restart_required_mtimes
RESTART_REQUIRED_PATHS.to_h do |path|
[path, File.mtime(root.join(path)).to_f]
rescue Errno::ENOENT
[path, nil]
end
end

# A file count alongside the newest mtime. Between them these catch the three things that
# matter: a file changing (mtime moves), one being added, and one being deleted (count
# moves). Comparing counts avoids having to keep a hash of every path.
def signature(paths)
count = 0
latest = 0.0

paths.each do |path|
mtime = File.mtime(path).to_f
count += 1
latest = mtime if mtime > latest
rescue Errno::ENOENT
# Deleted between the glob and the stat; the next check will see a stable state.
end

[count, latest]
end
end
end
end
82 changes: 82 additions & 0 deletions lib/hanami/reloader/middleware.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module Hanami
module Reloader
# Rack middleware that reloads the app in place when its source files change.
#
# This sits *outside* the Hanami app rather than in the app's own middleware stack, because a
# reload replaces everything inside that stack. Wrapping from the outside means the request is
# dispatched into freshly loaded code, instead of into the code that was live when the request
# arrived.
#
# Files are checked once per request rather than watched in the background, so a reload only
# happens when there is something to serve, and never lands halfway through an edit.
#
# @api private
# @since 3.1.0
class Middleware
# @api private
# @since 3.1.0
def initialize(app, file_checker:, slice: nil, out: $stdout)
@app = app
@file_checker = file_checker
@slice = slice
@out = out
@mutex = Mutex.new
end

# @api private
# @since 3.1.0
def call(env)
@mutex.synchronize { check_for_changes }

@app.call(env)
end

private

# @api private
# @since 3.1.0
def check_for_changes
restart_required = @file_checker.restart_required
warn_restart_required(restart_required) if restart_required.any?

return unless @file_checker.updated?

reload!

# Only once the reload has succeeded, so a file that raises is retried on the next request
# instead of being silently skipped.
@file_checker.commit!
end

# @api private
# @since 3.1.0
def reload!
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)

slice.reload!

elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
@out.puts("[hanami] Reloaded in #{(elapsed * 1000).round}ms")
end

# @api private
# @since 3.1.0
def warn_restart_required(paths)
@out.puts(
"[hanami] #{paths.join(', ')} cannot be reloaded. " \
"Restart the server to apply your changes."
)
end

# Resolved lazily: the middleware is built while the app is being loaded.
#
# @api private
# @since 3.1.0
def slice
@slice || Hanami.app
end
end
end
end
Loading