diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr new file mode 100644 index 000000000..b5ad89598 --- /dev/null +++ b/examples/config/settings/development.cr @@ -0,0 +1,15 @@ +Marten.configure :development do |config| + # Enable debug mode for development + config.debug = true + + # Enable live reload for development + config.live_reload_enabled = true + + # Add the live reload middleware to the stack + config.middleware = [ + Marten::LiveReload, + # ... other middleware ... + ] + + # Other development settings... +end diff --git a/spec/marten/handlers/live_reload_spec.cr b/spec/marten/handlers/live_reload_spec.cr new file mode 100644 index 000000000..30287ea28 --- /dev/null +++ b/spec/marten/handlers/live_reload_spec.cr @@ -0,0 +1,27 @@ +require "./spec_helper" + +module Marten + module Handlers + describe LiveReload do + describe "#get" do + it "sets up SSE response with correct headers" do + request = HTTP::Request.new("GET", "/live-reload") + handler = LiveReload.new(request) + response = handler.dispatch + + response.content_type.should eq "text/event-stream" + response.status.should eq 200 + response.headers["Cache-Control"].should eq "no-cache" + response.headers["Connection"].should eq "keep-alive" + end + end + + describe ".broadcast" do + it "allows broadcasting messages" do + # Broadcasting should not raise errors + LiveReload.broadcast("test-message") + end + end + end + end +end diff --git a/spec/marten/middleware/live_reload_spec.cr b/spec/marten/middleware/live_reload_spec.cr new file mode 100644 index 000000000..4272779b3 --- /dev/null +++ b/spec/marten/middleware/live_reload_spec.cr @@ -0,0 +1,98 @@ +require "./spec_helper" + +module Marten + describe LiveReload do + it "injects reload script into HTML responses when enabled" do + original_settings = Marten.settings.debug? + begin + Marten.settings.debug = true + Marten.settings.live_reload_enabled = true + middleware = Marten::LiveReload.new + + response = Marten::HTTP::Response.new("Test", content_type: "text/html") + + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.call(request, -> { response }) + + response.content.should contain("new EventSource('/live-reload')") + ensure + Marten.settings.debug = original_settings + end + end + + it "doesn't inject script when debug mode is disabled" do + original_settings = Marten.settings.debug? + begin + Marten.settings.debug = false + middleware = Marten::LiveReload.new + + response = Marten::HTTP::Response.new("Test", content_type: "text/html") + + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.call(request, -> { response }) + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end + end + + it "doesn't inject script for non-HTML responses" do + original_settings = Marten.settings.debug? + begin + Marten.settings.debug = true + middleware = Marten::LiveReload.new + + response = Marten::HTTP::Response.new("{\"test\": true}", content_type: "application/json") + + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.call(request, -> { response }) + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end + end + + it "doesn't inject script for AJAX requests" do + original_settings = Marten.settings.debug? + begin + Marten.settings.debug = true + middleware = Marten::LiveReload.new + + response = Marten::HTTP::Response.new("Test", content_type: "text/html") + + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com", "X-Requested-With" => "XMLHttpRequest"} + ) + ) + response = middleware.call(request, -> { response }) + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end + end + end +end diff --git a/spec/marten/server/live_reload_spec.cr b/spec/marten/server/live_reload_spec.cr new file mode 100644 index 000000000..1783e80bd --- /dev/null +++ b/spec/marten/server/live_reload_spec.cr @@ -0,0 +1,71 @@ +require "./spec_helper" + +module Marten + describe Server::LiveReload do + describe ".running?" do + it "returns false by default" do + Marten::Server::LiveReload.running?.should be_false + end + + it "returns true when the server is started" do + begin + Marten::Server::LiveReload.start + # Give watcher time to start + sleep 0.1.seconds + Marten::Server::LiveReload.running?.should be_true + ensure + Marten::Server::LiveReload.stop + end + end + end + + describe ".start" do + it "starts the file watcher with default patterns" do + begin + Marten::Server::LiveReload.start + Marten::Server::LiveReload.running?.should be_true + + File.write("src/temp_test.cr", "# test") + # Give the watcher time to detect the change + sleep 0.1.seconds + + # Cleanup + File.delete("src/temp_test.cr") + ensure + Marten::Server::LiveReload.stop + end + end + + it "starts the file watcher with custom patterns" do + begin + custom_patterns = ["custom/**/*.cr"] + Dir.mkdir_p("custom") + + Marten::Server::LiveReload.start(custom_patterns) + # Give watcher time to start + sleep 0.1.seconds + Marten::Server::LiveReload.running?.should be_true + + File.write("custom/temp_test.cr", "# test") + # Give the watcher time to detect the change + sleep 0.1.seconds + + # Cleanup + File.delete("custom/temp_test.cr") + Dir.delete("custom") + ensure + Marten::Server::LiveReload.stop + end + end + end + + describe ".stop" do + it "stops the file watcher" do + Marten::Server::LiveReload.start + Marten::Server::LiveReload.stop + + Marten::Server::LiveReload.running?.should be_false + end + end + end +end diff --git a/src/marten/cli/manage/command/new/templates/project/config/settings/development.cr.ecr b/src/marten/cli/manage/command/new/templates/project/config/settings/development.cr.ecr index 2e07ee132..43225946f 100644 --- a/src/marten/cli/manage/command/new/templates/project/config/settings/development.cr.ecr +++ b/src/marten/cli/manage/command/new/templates/project/config/settings/development.cr.ecr @@ -3,6 +3,15 @@ Marten.configure :development do |config| config.host = "127.0.0.1" config.port = 8000 + # Enable live reload for development + config.live_reload_enabled = true + + # Add live reload middleware + config.middleware = [ + Marten::LiveReload, + *config.middleware + ] + # Print sent emails to the standard output in development. # https://martenframework.com/docs/development/reference/settings#emailing-settings config.emailing.backend = Marten::Emailing::Backend::Development.new(print_emails: true) diff --git a/src/marten/conf/global_settings.cr b/src/marten/conf/global_settings.cr index c498bab1d..93ec86c20 100644 --- a/src/marten/conf/global_settings.cr +++ b/src/marten/conf/global_settings.cr @@ -16,6 +16,8 @@ module Marten @target_env : String? @trailing_slash : TrailingSlash @unsupported_http_method_strategy : UnsupportedHttpMethodStrategy + @live_reload_enabled : Bool = false + @live_reload_patterns : Array(String) = Server::LiveReload::DEFAULT_WATCH_PATTERNS # Returns the explicit list of allowed hosts for the application. getter allowed_hosts @@ -109,6 +111,13 @@ module Marten # Returns the strategy to use when an unsupported HTTP method is encountered. getter unsupported_http_method_strategy + # Returns whether live reload is enabled in development. + getter live_reload_enabled + # :ditto: + getter? live_reload_enabled + # Returns the watched file patterns for live reload. + getter live_reload_patterns + # Returns a boolean indicating whether the X-Forwarded-Host header is used to look for the host. getter use_x_forwarded_host @@ -225,6 +234,10 @@ module Marten # Allows to set whether the X-Forwarded-Proto header should be used to determine whether a request is secure. setter use_x_forwarded_proto + # Live reload settings setters. + setter live_reload_enabled + setter live_reload_patterns + # :nodoc: def self.register_settings_namespace(ns : String) if settings_namespace_registered?(ns) @@ -287,6 +300,8 @@ module Marten @use_x_forwarded_port = false @use_x_forwarded_proto = false @x_frame_options = "DENY" + @live_reload_enabled = false + @live_reload_patterns = Server::LiveReload::DEFAULT_WATCH_PATTERNS end # Provides access to assets settings. diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr new file mode 100644 index 000000000..dd4e1a3cf --- /dev/null +++ b/src/marten/handlers/live_reload.cr @@ -0,0 +1,48 @@ +module Marten + module Handlers + # Handler that provides Server-Sent Events (SSE) for live reload functionality. + # This handler is automatically mounted when live reload is enabled in development mode. + class LiveReload < Base + @@channels = [] of Channel(String) + + def get + channel = Channel(String).new + @@channels << channel + + streamed_content = Iterator.of do + begin + message = channel.receive + "data: #{message}\n\n" + rescue Channel::ClosedError + cleanup(channel) + Iterator::Stop::INSTANCE + end + end + + respond( + streamed_content, + content_type: "text/event-stream", + status: 200 + ).tap do |response| + response.headers["Cache-Control"] = "no-cache" + response.headers["Connection"] = "keep-alive" + end + end + + def self.broadcast(message : String) + @@channels.reject! do |channel| + begin + channel.send(message) + false + rescue Channel::ClosedError + true + end + end + end + + private def cleanup(channel) + @@channels.delete(channel) + end + end + end +end diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr new file mode 100644 index 000000000..c2bb54493 --- /dev/null +++ b/src/marten/middleware/live_reload.cr @@ -0,0 +1,29 @@ +module Marten + # Middleware that injects the live reload script into HTML responses. + # This middleware is only active in development mode when live reload is enabled. + class LiveReload < Middleware + def call(request : HTTP::Request, get_response : Proc(HTTP::Response)) : HTTP::Response + response = get_response.call + + return response unless should_inject?(request, response) + + body = response.content.to_s + # Find the position of tag (case-insensitive) + insert_at = body.downcase.rindex("") + return response unless insert_at + + script = {{ read_file "#{__DIR__}/live_reload.html" }} + + response.content = body.insert(insert_at, script) + response + end + + private def should_inject?(request : HTTP::Request, response : HTTP::Response) + return false unless Marten.settings.debug? + return false unless Marten.settings.live_reload_enabled? + return false unless response.content_type.downcase.starts_with?("text/html") + return false if request.headers["X-Requested-With"]? == "XMLHttpRequest" + true + end + end +end diff --git a/src/marten/middleware/live_reload.html b/src/marten/middleware/live_reload.html new file mode 100644 index 000000000..23e45332d --- /dev/null +++ b/src/marten/middleware/live_reload.html @@ -0,0 +1,7 @@ + diff --git a/src/marten/server.cr b/src/marten/server.cr index 52db4dd89..cd8fa42b9 100644 --- a/src/marten/server.cr +++ b/src/marten/server.cr @@ -29,6 +29,11 @@ module Marten else instance.bind_tcp(Marten.settings.host, Marten.settings.port, Marten.settings.port_reuse) end + + # Start live reload if enabled in development mode + if Marten.settings.debug? && Marten.settings.live_reload_enabled? + LiveReload.start(Marten.settings.live_reload_patterns) + end end # Starts the server. @@ -39,6 +44,7 @@ module Marten # Stops the server. def self.stop : Nil instance.close + LiveReload.stop if LiveReload.running? end end end diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr new file mode 100644 index 000000000..f86f6f325 --- /dev/null +++ b/src/marten/server/file_watcher.cr @@ -0,0 +1,67 @@ +module Marten + module Server + # A simple file watcher that monitors files for changes. + # Used by the live reload server to detect when files have been modified. + class FileWatcher + property patterns : Array(String) + property debounce : Time::Span + @on_change : Proc(Nil)? + @running = false + @last_change = Time.monotonic + @timestamps = {} of String => Time + + def initialize(@patterns = [] of String, @debounce = 1.second) + end + + # Set the callback to be called when files change + def on_change(&block : -> Nil) + @on_change = block + end + + # Start watching for file changes + def start : Nil + return if @running + @running = true + + while @running + check_for_changes + sleep(POLL_INTERVAL) + end + end + + # Stop watching for file changes + def stop : Nil + @running = false + end + + private POLL_INTERVAL = 0.5.seconds + + private def check_for_changes + changed = false + + patterns.each do |pattern| + Dir.glob(pattern) do |file| + next if file.ends_with?(".tmp") + next if file.ends_with?(".lock") + + begin + current_time = File.info(file).modification_time + if @timestamps[file]? != current_time + @timestamps[file] = current_time + changed = true + end + rescue ex : File::Error | Exception + # File may have been removed or is temporarily inaccessible; skip it + next + end + end + end + + if changed && (Time.monotonic - @last_change) > @debounce + @last_change = Time.monotonic + @on_change.try &.call + end + end + end + end +end diff --git a/src/marten/server/live_reload.cr b/src/marten/server/live_reload.cr new file mode 100644 index 000000000..6dddbe202 --- /dev/null +++ b/src/marten/server/live_reload.cr @@ -0,0 +1,44 @@ +module Marten + module Server + module LiveReload + @@watcher : FileWatcher? + + DEFAULT_WATCH_PATTERNS = [ + "src/**/*.cr", + "src/**/*.ecr", + "src/**/*.html", + "src/assets/**/*", + "config/**/*", + ] + + def self.running? : Bool + !@@watcher.nil? + end + + def self.start(patterns = DEFAULT_WATCH_PATTERNS) : Nil + return if running? + setup_watcher(patterns) + end + + def self.stop : Nil + if watcher = @@watcher + watcher.stop + @@watcher = nil + end + end + + private def self.setup_watcher(patterns) + # Create the watcher synchronously so running? can reflect state immediately + watcher = FileWatcher.new(patterns: patterns) + watcher.on_change do + Marten::Handlers::LiveReload.broadcast("reload") + end + @@watcher = watcher + # Start watching asynchronously + spawn do + watcher.start + end + end + end + end +end