From 3a3bdb00de71d7bb6bec1e6ae838ecb8c19ad861 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Sat, 6 Sep 2025 08:33:12 +1200 Subject: [PATCH 01/24] Add live reload functionality for development --- examples/config/settings/development.cr | 28 +++++ spec/marten/middleware/live_reload_spec.cr | 84 ++++++++++++++ spec/marten/server/live_reload_spec.cr | 105 ++++++++++++++++++ src/marten/middleware/live_reload.cr | 56 ++++++++++ src/marten/server/file_watcher.cr | 66 +++++++++++ src/marten/server/live_reload.cr | 121 +++++++++++++++++++++ 6 files changed, 460 insertions(+) create mode 100644 examples/config/settings/development.cr create mode 100644 spec/marten/middleware/live_reload_spec.cr create mode 100644 spec/marten/server/live_reload_spec.cr create mode 100644 src/marten/middleware/live_reload.cr create mode 100644 src/marten/server/file_watcher.cr create mode 100644 src/marten/server/live_reload.cr diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr new file mode 100644 index 000000000..98ff5f482 --- /dev/null +++ b/examples/config/settings/development.cr @@ -0,0 +1,28 @@ +Marten.configure :development do |config| + # Enable debug mode for development + config.debug = true + + # Enable live reload for development + config.live_reload_enabled = true + + # Optional: customize live reload settings + # config.live_reload_host = "localhost" # Default + # config.live_reload_port = 35729 # Default + # config.live_reload_debounce = 1000 # Default: 1 second + + # Optional: customize file patterns to watch + # config.live_reload_patterns = [ + # "src/**/*.cr", + # "src/**/*.ecr", + # "src/assets/**/*", + # "config/**/*", + # ] + + # Add the live reload middleware to the stack + config.middleware = [ + Marten::Middleware::LiveReload.new, + # ... other middleware ... + ] + + # Other development settings... +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..3eed793ec --- /dev/null +++ b/spec/marten/middleware/live_reload_spec.cr @@ -0,0 +1,84 @@ +require "./spec_helper" + +describe Marten::Middleware::LiveReload do + it "injects reload script into HTML responses when enabled" do + original_settings = Marten.settings.debug? + begin + Marten.settings.debug = true + middleware = Marten::Middleware::LiveReload.new + + response = HTTP::Response.new( + content_type: "text/html", + content: "Test" + ) + + request = HTTP::Request.new("GET", "/") + response = middleware.process_request(request) { response } + + response.content.should contain("new WebSocket('ws://localhost:35729/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::Middleware::LiveReload.new + + response = HTTP::Response.new( + content_type: "text/html", + content: "Test" + ) + + request = HTTP::Request.new("GET", "/") + response = middleware.process_request(request) { response } + + response.content.should_not contain("WebSocket") + 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::Middleware::LiveReload.new + + response = HTTP::Response.new( + content_type: "application/json", + content: "{\"test\": true}" + ) + + request = HTTP::Request.new("GET", "/") + response = middleware.process_request(request) { response } + + response.content.should_not contain("WebSocket") + 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::Middleware::LiveReload.new + + response = HTTP::Response.new( + content_type: "text/html", + content: "Test" + ) + + request = HTTP::Request.new("GET", "/") + request.headers["X-Requested-With"] = "XMLHttpRequest" + response = middleware.process_request(request) { response } + + response.content.should_not contain("WebSocket") + ensure + Marten.settings.debug = original_settings + 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..e37389445 --- /dev/null +++ b/spec/marten/server/live_reload_spec.cr @@ -0,0 +1,105 @@ +require "./spec_helper" + +describe Marten::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 + Marten::Server::LiveReload.running?.should be_true + ensure + Marten::Server::LiveReload.stop + end + end + end + + describe ".clients" do + it "returns an empty array by default" do + Marten::Server::LiveReload.clients.should be_empty + end + end + + describe ".start" do + it "starts the WebSocket server with default options" do + begin + Marten::Server::LiveReload.start + Marten::Server::LiveReload.running?.should be_true + + # Test WebSocket connection + socket = HTTP::WebSocket.new("ws://localhost:35729/live_reload") + socket.on_message do |message| + message.should eq "reload" + socket.close + end + + # Trigger a reload + Marten::Server::LiveReload.trigger_reload + ensure + Marten::Server::LiveReload.stop + end + end + + it "starts the WebSocket server with custom options" do + begin + custom_port = 35730 + Marten::Server::LiveReload.start(port: custom_port) + Marten::Server::LiveReload.running?.should be_true + + # Test WebSocket connection + socket = HTTP::WebSocket.new("ws://localhost:#{custom_port}/live_reload") + socket.on_message do |message| + message.should eq "reload" + socket.close + end + + # Trigger a reload + Marten::Server::LiveReload.trigger_reload + ensure + Marten::Server::LiveReload.stop + end + end + end + + describe ".stop" do + it "stops the server and clears clients" do + Marten::Server::LiveReload.start + Marten::Server::LiveReload.stop + + Marten::Server::LiveReload.running?.should be_false + Marten::Server::LiveReload.clients.should be_empty + end + end + + describe ".trigger_reload" do + it "sends reload message to all connected clients" do + messages_received = 0 + + begin + Marten::Server::LiveReload.start + + # Connect two clients + socket1 = HTTP::WebSocket.new("ws://localhost:35729/live_reload") + socket1.on_message { |_| messages_received += 1 } + + socket2 = HTTP::WebSocket.new("ws://localhost:35729/live_reload") + socket2.on_message { |_| messages_received += 1 } + + # Allow time for connections to establish + sleep 0.1 + + # Trigger reload + Marten::Server::LiveReload.trigger_reload + + # Allow time for messages to be received + sleep 0.1 + + messages_received.should eq 2 + ensure + Marten::Server::LiveReload.stop + 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..3e536c68b --- /dev/null +++ b/src/marten/middleware/live_reload.cr @@ -0,0 +1,56 @@ +module Marten + module Middleware + # 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 < Base + def initialize + @host = Marten.settings.live_reload_host + @port = Marten.settings.live_reload_port + end + + def process_request(request, get_response) + response = get_response.call(request) + + return response unless should_inject?(request, response) + + body = response.content.to_s + insert_at = body.rindex("") + return response unless insert_at + + response.content = body.insert(insert_at, live_reload_script) + response + end + + private def should_inject?(request, response) + return false unless Marten.settings.debug? + return false unless Marten.settings.live_reload_enabled? + return false unless response.content_type == "text/html" + return false if request.headers["X-Requested-With"]? == "XMLHttpRequest" + true + end + + private def live_reload_script + <<-SCRIPT + + SCRIPT + end + 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..9a36aad16 --- /dev/null +++ b/src/marten/server/file_watcher.cr @@ -0,0 +1,66 @@ +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 + + spawn do + while @running + check_for_changes + sleep 0.5 + end + end + end + + # Stop watching for file changes + def stop : Nil + @running = false + end + + 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 + 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..fd5a386cf --- /dev/null +++ b/src/marten/server/live_reload.cr @@ -0,0 +1,121 @@ +module Marten + module Server + # Provides a WebSocket-based live reload server for development. + # This server is only enabled in development mode when the live_reload setting is enabled. + module LiveReload + @@clients = [] of HTTP::WebSocket + @@server : HTTP::Server? + @@watcher : FileWatcher? + + # List of file patterns to watch for changes + DEFAULT_WATCH_PATTERNS = [ + "src/**/*.cr", + "src/**/*.ecr", + "src/assets/**/*", + "config/**/*", + ] + + # Default options for the live reload server + DEFAULT_OPTIONS = { + port: 35729, + host: "localhost", + patterns: DEFAULT_WATCH_PATTERNS, + debounce: 1.second, + } + + # Returns whether the live reload server is running + def self.running? : Bool + !@@server.nil? + end + + # Returns all connected WebSocket clients + def self.clients : Array(HTTP::WebSocket) + @@clients + end + + # Start the live reload server with the given options + def self.start(options = DEFAULT_OPTIONS) : Nil + return if running? + + spawn do + setup_server(options) + setup_watcher(options) + end + end + + # Stop the live reload server if it's running + def self.stop : Nil + if server = @@server + server.close + @@server = nil + end + + if watcher = @@watcher + watcher.stop + @@watcher = nil + end + + @@clients.clear + end + + # Trigger a reload on all connected clients + def self.trigger_reload : Nil + clients.each do |client| + begin + client.send("reload") + rescue + @@clients.delete(client) + end + end + end + + private def self.setup_server(options) + server = HTTP::Server.new do |context| + if context.request.path == "/live_reload" + ws_handler = HTTP::WebSocketHandler.new do |ws, _ctx| + @@clients << ws + + # Keep connection alive with ping/pong + spawn do + loop do + begin + ws.ping + sleep 30 + rescue + break + end + end + end + + # Handle disconnection + ws.on_close do + @@clients.delete(ws) + end + end + + ws_handler.call(context) + end + end + + server.bind_tcp options[:host], options[:port] + server.listen + + @@server = server + end + + private def self.setup_watcher(options) + watcher = FileWatcher.new( + patterns: options[:patterns], + debounce: options[:debounce] + ) + + watcher.on_change do + trigger_reload + end + + watcher.start + @@watcher = watcher + end + end + end +end From ec898727846595f0f7f04286c5524b0306c35675 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Sat, 6 Sep 2025 09:18:54 +1200 Subject: [PATCH 02/24] feat: Implement live reload middleware and server components - Add LiveReload middleware for injecting WebSocket client script - Add live reload settings to GlobalSettings - Update server to start live reload server in development mode - Fix WebSocket client handling in live reload server - Add comprehensive tests for live reload functionality --- spec/marten/middleware/live_reload_spec.cr | 86 +++++++++++++------- spec/marten/server/live_reload_spec.cr | 13 +-- src/marten/conf/global_settings.cr | 35 ++++++++ src/marten/middleware/live_reload.cr | 94 +++++++++++----------- src/marten/server.cr | 13 +++ src/marten/server/live_reload.cr | 12 +-- 6 files changed, 166 insertions(+), 87 deletions(-) diff --git a/spec/marten/middleware/live_reload_spec.cr b/spec/marten/middleware/live_reload_spec.cr index 3eed793ec..814dd97b9 100644 --- a/spec/marten/middleware/live_reload_spec.cr +++ b/spec/marten/middleware/live_reload_spec.cr @@ -1,19 +1,27 @@ require "./spec_helper" -describe Marten::Middleware::LiveReload do +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 - middleware = Marten::Middleware::LiveReload.new + Marten.settings.live_reload_enabled = true + middleware = Marten::LiveReload.new - response = HTTP::Response.new( - content_type: "text/html", - content: "Test" - ) + response = Marten::HTTP::Response.new("Test", content_type: "text/html") - request = HTTP::Request.new("GET", "/") - response = middleware.process_request(request) { response } + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", +headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.chain( + request, + -> { response } + ) response.content.should contain("new WebSocket('ws://localhost:35729/live_reload')") ensure @@ -25,15 +33,21 @@ describe Marten::Middleware::LiveReload do original_settings = Marten.settings.debug? begin Marten.settings.debug = false - middleware = Marten::Middleware::LiveReload.new + middleware = Marten::LiveReload.new - response = HTTP::Response.new( - content_type: "text/html", - content: "Test" - ) + response = Marten::HTTP::Response.new("Test", content_type: "text/html") - request = HTTP::Request.new("GET", "/") - response = middleware.process_request(request) { response } + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", +headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.chain( + request, + -> { response } + ) response.content.should_not contain("WebSocket") ensure @@ -45,15 +59,21 @@ describe Marten::Middleware::LiveReload do original_settings = Marten.settings.debug? begin Marten.settings.debug = true - middleware = Marten::Middleware::LiveReload.new + middleware = Marten::LiveReload.new - response = HTTP::Response.new( - content_type: "application/json", - content: "{\"test\": true}" - ) + response = Marten::HTTP::Response.new("{\"test\": true}", content_type: "application/json") - request = HTTP::Request.new("GET", "/") - response = middleware.process_request(request) { response } + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", +headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.chain( + request, + -> { response } + ) response.content.should_not contain("WebSocket") ensure @@ -65,16 +85,21 @@ describe Marten::Middleware::LiveReload do original_settings = Marten.settings.debug? begin Marten.settings.debug = true - middleware = Marten::Middleware::LiveReload.new + middleware = Marten::LiveReload.new - response = HTTP::Response.new( - content_type: "text/html", - content: "Test" - ) + response = Marten::HTTP::Response.new("Test", content_type: "text/html") - request = HTTP::Request.new("GET", "/") - request.headers["X-Requested-With"] = "XMLHttpRequest" - response = middleware.process_request(request) { response } + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", +headers: ::HTTP::Headers{"Host" => "example.com", "X-Requested-With" => "XMLHttpRequest"} + ) + ) + response = middleware.chain( + request, + -> { response } + ) response.content.should_not contain("WebSocket") ensure @@ -82,3 +107,4 @@ describe Marten::Middleware::LiveReload do end end end +end diff --git a/spec/marten/server/live_reload_spec.cr b/spec/marten/server/live_reload_spec.cr index e37389445..e2d4ff6c4 100644 --- a/spec/marten/server/live_reload_spec.cr +++ b/spec/marten/server/live_reload_spec.cr @@ -1,6 +1,8 @@ require "./spec_helper" +require "http/web_socket" -describe Marten::Server::LiveReload do +module Marten + describe Server::LiveReload do describe ".running?" do it "returns false by default" do Marten::Server::LiveReload.running?.should be_false @@ -29,7 +31,7 @@ describe Marten::Server::LiveReload do Marten::Server::LiveReload.running?.should be_true # Test WebSocket connection - socket = HTTP::WebSocket.new("ws://localhost:35729/live_reload") +socket = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) socket.on_message do |message| message.should eq "reload" socket.close @@ -49,7 +51,7 @@ describe Marten::Server::LiveReload do Marten::Server::LiveReload.running?.should be_true # Test WebSocket connection - socket = HTTP::WebSocket.new("ws://localhost:#{custom_port}/live_reload") +socket = HTTP::WebSocket::Client.new("localhost", "/live_reload", custom_port) socket.on_message do |message| message.should eq "reload" socket.close @@ -81,10 +83,10 @@ describe Marten::Server::LiveReload do Marten::Server::LiveReload.start # Connect two clients - socket1 = HTTP::WebSocket.new("ws://localhost:35729/live_reload") + socket1 = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) socket1.on_message { |_| messages_received += 1 } - socket2 = HTTP::WebSocket.new("ws://localhost:35729/live_reload") + socket2 = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) socket2.on_message { |_| messages_received += 1 } # Allow time for connections to establish @@ -103,3 +105,4 @@ describe Marten::Server::LiveReload do end end end +end diff --git a/src/marten/conf/global_settings.cr b/src/marten/conf/global_settings.cr index 9f83cfff1..9bdc5ed86 100644 --- a/src/marten/conf/global_settings.cr +++ b/src/marten/conf/global_settings.cr @@ -15,6 +15,11 @@ module Marten @target_env : String? @trailing_slash : TrailingSlash @unsupported_http_method_strategy : UnsupportedHttpMethodStrategy + @live_reload_enabled : Bool + @live_reload_host : String + @live_reload_port : Int32 + @live_reload_patterns : Array(String) + @live_reload_debounce : Int32 # Returns the explicit list of allowed hosts for the application. getter allowed_hosts @@ -102,6 +107,19 @@ 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 host used by the live reload server. + getter live_reload_host + # Returns the port used by the live reload server. + getter live_reload_port + # Returns the watched file patterns for live reload. + getter live_reload_patterns + # Returns the debounce (ms) used for live reload notifications. + getter live_reload_debounce + # Returns a boolean indicating whether the X-Forwarded-Host header is used to look for the host. getter use_x_forwarded_host @@ -212,6 +230,13 @@ 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_host + setter live_reload_port + setter live_reload_patterns + setter live_reload_debounce + # :nodoc: def self.register_settings_namespace(ns : String) if settings_namespace_registered?(ns) @@ -272,6 +297,16 @@ module Marten @use_x_forwarded_port = false @use_x_forwarded_proto = false @x_frame_options = "DENY" + @live_reload_enabled = false + @live_reload_host = "localhost" + @live_reload_port = 35729 + @live_reload_patterns = [ + "src/**/*.cr", + "src/**/*.ecr", + "src/assets/**/*", + "config/**/*", + ] + @live_reload_debounce = 1000 end # Provides access to assets settings. diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 3e536c68b..a5bf9a096 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -1,56 +1,58 @@ module Marten - module Middleware - # 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 < Base - def initialize - @host = Marten.settings.live_reload_host - @port = Marten.settings.live_reload_port - end + # 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 + @host : String + @port : Int32 - def process_request(request, get_response) - response = get_response.call(request) + def initialize + super + @host = Marten.settings.live_reload_host + @port = Marten.settings.live_reload_port + end + + def call(request : Marten::HTTP::Request, get_response : Proc(Marten::HTTP::Response)) : Marten::HTTP::Response + response = get_response.call - return response unless should_inject?(request, response) + return response unless should_inject?(request, response) - body = response.content.to_s - insert_at = body.rindex("") - return response unless insert_at + body = response.content.to_s + insert_at = body.rindex("") + return response unless insert_at - response.content = body.insert(insert_at, live_reload_script) - response - end + response.content = body.insert(insert_at, live_reload_script) + response + end - private def should_inject?(request, response) - return false unless Marten.settings.debug? - return false unless Marten.settings.live_reload_enabled? - return false unless response.content_type == "text/html" - return false if request.headers["X-Requested-With"]? == "XMLHttpRequest" - true - end + private def should_inject?(request : Marten::HTTP::Request, response : Marten::HTTP::Response) + return false unless Marten.settings.debug? + return false unless Marten.settings.live_reload_enabled? + return false unless response.content_type == "text/html" + return false if request.headers["X-Requested-With"]? == "XMLHttpRequest" + true + end - private def live_reload_script - <<-SCRIPT - - SCRIPT - end + private def live_reload_script + <<-SCRIPT + + SCRIPT end end end diff --git a/src/marten/server.cr b/src/marten/server.cr index 43053019b..156ccfb60 100644 --- a/src/marten/server.cr +++ b/src/marten/server.cr @@ -1,3 +1,5 @@ +require "http" + module Marten # Wrapper around the Marten server. module Server @@ -25,6 +27,16 @@ module Marten # Setups the server (TCP binding). def self.setup : Nil instance.bind_tcp(Marten.settings.host, Marten.settings.port, Marten.settings.port_reuse) + + # Start live reload server if enabled in development mode + if Marten.settings.debug? && Marten.settings.live_reload_enabled? + LiveReload.start( + host: Marten.settings.live_reload_host, + port: Marten.settings.live_reload_port, + patterns: Marten.settings.live_reload_patterns, + debounce: Time::Span.new(milliseconds: Marten.settings.live_reload_debounce) + ) + end end # Starts the server. @@ -35,6 +47,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/live_reload.cr b/src/marten/server/live_reload.cr index fd5a386cf..7269134f0 100644 --- a/src/marten/server/live_reload.cr +++ b/src/marten/server/live_reload.cr @@ -3,8 +3,8 @@ module Marten # Provides a WebSocket-based live reload server for development. # This server is only enabled in development mode when the live_reload setting is enabled. module LiveReload - @@clients = [] of HTTP::WebSocket - @@server : HTTP::Server? + @@clients = [] of ::HTTP::WebSocket + @@server : ::HTTP::Server? @@watcher : FileWatcher? # List of file patterns to watch for changes @@ -29,7 +29,7 @@ module Marten end # Returns all connected WebSocket clients - def self.clients : Array(HTTP::WebSocket) + def self.clients : Array(::HTTP::WebSocket) @@clients end @@ -70,9 +70,9 @@ module Marten end private def self.setup_server(options) - server = HTTP::Server.new do |context| - if context.request.path == "/live_reload" - ws_handler = HTTP::WebSocketHandler.new do |ws, _ctx| + server = ::HTTP::Server.new do |context| + if context.request.resource == "/live_reload" + ws_handler = ::HTTP::WebSocketHandler.new do |ws, _ctx| @@clients << ws # Keep connection alive with ping/pong From e7ea2eb5f59867024d7c842b063d76402a1f9114 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 13:06:34 +1200 Subject: [PATCH 03/24] Add live reload middleware --- src/marten/middleware/live_reload.cr | 58 +++++++++++----------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index a5bf9a096..53d0880c7 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -2,57 +2,45 @@ 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 - @host : String - @port : Int32 - - def initialize - super - @host = Marten.settings.live_reload_host - @port = Marten.settings.live_reload_port - end - - def call(request : Marten::HTTP::Request, get_response : Proc(Marten::HTTP::Response)) : Marten::HTTP::Response + 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 - insert_at = body.rindex("") + # Find the position of tag (case-insensitive) + insert_at = body.downcase.rindex("") return response unless insert_at - response.content = body.insert(insert_at, live_reload_script) - response - end - - private def should_inject?(request : Marten::HTTP::Request, response : Marten::HTTP::Response) - return false unless Marten.settings.debug? - return false unless Marten.settings.live_reload_enabled? - return false unless response.content_type == "text/html" - return false if request.headers["X-Requested-With"]? == "XMLHttpRequest" - true - end - - private def live_reload_script - <<-SCRIPT + script = <<-SCRIPT SCRIPT + + 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 From 0759ab467bae9a310d5045732e1cb5bafc950f20 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 13:07:30 +1200 Subject: [PATCH 04/24] Add live reload implementation without test project --- examples/config/settings/development.cr | 18 ++-- .../config/settings/development.cr.ecr | 9 ++ src/marten/conf/global_settings.cr | 26 +----- src/marten/handlers/live_reload 2.cr | 46 ++++++++++ src/marten/handlers/live_reload.cr | 41 +++++++++ src/marten/server.cr | 9 +- src/marten/server/live_reload.cr | 90 ++----------------- 7 files changed, 115 insertions(+), 124 deletions(-) create mode 100644 src/marten/handlers/live_reload 2.cr create mode 100644 src/marten/handlers/live_reload.cr diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr index 98ff5f482..f291946ba 100644 --- a/examples/config/settings/development.cr +++ b/examples/config/settings/development.cr @@ -5,18 +5,14 @@ Marten.configure :development do |config| # Enable live reload for development config.live_reload_enabled = true - # Optional: customize live reload settings - # config.live_reload_host = "localhost" # Default - # config.live_reload_port = 35729 # Default - # config.live_reload_debounce = 1000 # Default: 1 second - # Optional: customize file patterns to watch - # config.live_reload_patterns = [ - # "src/**/*.cr", - # "src/**/*.ecr", - # "src/assets/**/*", - # "config/**/*", - # ] + config.live_reload_patterns = [ + "src/**/*.cr", + "src/**/*.ecr", + "src/**/*.html", + "src/assets/**/*", + "config/**/*", + ] # Add the live reload middleware to the stack config.middleware = [ 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 9bdc5ed86..d05b51497 100644 --- a/src/marten/conf/global_settings.cr +++ b/src/marten/conf/global_settings.cr @@ -15,11 +15,8 @@ module Marten @target_env : String? @trailing_slash : TrailingSlash @unsupported_http_method_strategy : UnsupportedHttpMethodStrategy - @live_reload_enabled : Bool - @live_reload_host : String - @live_reload_port : Int32 - @live_reload_patterns : Array(String) - @live_reload_debounce : Int32 + @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 @@ -111,14 +108,8 @@ module Marten getter live_reload_enabled # :ditto: getter? live_reload_enabled - # Returns the host used by the live reload server. - getter live_reload_host - # Returns the port used by the live reload server. - getter live_reload_port # Returns the watched file patterns for live reload. getter live_reload_patterns - # Returns the debounce (ms) used for live reload notifications. - getter live_reload_debounce # Returns a boolean indicating whether the X-Forwarded-Host header is used to look for the host. getter use_x_forwarded_host @@ -232,10 +223,7 @@ module Marten # Live reload settings setters. setter live_reload_enabled - setter live_reload_host - setter live_reload_port setter live_reload_patterns - setter live_reload_debounce # :nodoc: def self.register_settings_namespace(ns : String) @@ -298,15 +286,7 @@ module Marten @use_x_forwarded_proto = false @x_frame_options = "DENY" @live_reload_enabled = false - @live_reload_host = "localhost" - @live_reload_port = 35729 - @live_reload_patterns = [ - "src/**/*.cr", - "src/**/*.ecr", - "src/assets/**/*", - "config/**/*", - ] - @live_reload_debounce = 1000 + @live_reload_patterns = Server::LiveReload::DEFAULT_WATCH_PATTERNS end # Provides access to assets settings. diff --git a/src/marten/handlers/live_reload 2.cr b/src/marten/handlers/live_reload 2.cr new file mode 100644 index 000000000..7c7c049e8 --- /dev/null +++ b/src/marten/handlers/live_reload 2.cr @@ -0,0 +1,46 @@ +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 + Log.info { "[LiveReload] SSE client connected" } + channel = Channel(String).new + @@channels << channel + + streamed_content = Iterator.of do + begin + message = channel.receive + Log.info { "[LiveReload] Broadcasting: #{message}" } + "data: #{message}\n\n" + rescue + Log.info { "[LiveReload] SSE client disconnected" } + 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.each do |channel| + spawn { channel.send(message) } + end + end + + private def cleanup(channel) + @@channels.delete(channel) + end + end + end +end diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr new file mode 100644 index 000000000..c57dd021b --- /dev/null +++ b/src/marten/handlers/live_reload.cr @@ -0,0 +1,41 @@ +module Marten + module Handlers + 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 + 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.each do |channel| + spawn { channel.send(message) } + end + end + + private def cleanup(channel) + @@channels.delete(channel) + end + end + end +end diff --git a/src/marten/server.cr b/src/marten/server.cr index 156ccfb60..60438f524 100644 --- a/src/marten/server.cr +++ b/src/marten/server.cr @@ -28,14 +28,9 @@ module Marten def self.setup : Nil instance.bind_tcp(Marten.settings.host, Marten.settings.port, Marten.settings.port_reuse) - # Start live reload server if enabled in development mode + # Start live reload if enabled in development mode if Marten.settings.debug? && Marten.settings.live_reload_enabled? - LiveReload.start( - host: Marten.settings.live_reload_host, - port: Marten.settings.live_reload_port, - patterns: Marten.settings.live_reload_patterns, - debounce: Time::Span.new(milliseconds: Marten.settings.live_reload_debounce) - ) + LiveReload.start(Marten.settings.live_reload_patterns) end end diff --git a/src/marten/server/live_reload.cr b/src/marten/server/live_reload.cr index 7269134f0..1bad42ad3 100644 --- a/src/marten/server/live_reload.cr +++ b/src/marten/server/live_reload.cr @@ -1,116 +1,40 @@ module Marten module Server - # Provides a WebSocket-based live reload server for development. - # This server is only enabled in development mode when the live_reload setting is enabled. module LiveReload - @@clients = [] of ::HTTP::WebSocket - @@server : ::HTTP::Server? @@watcher : FileWatcher? - # List of file patterns to watch for changes DEFAULT_WATCH_PATTERNS = [ "src/**/*.cr", "src/**/*.ecr", + "src/**/*.html", "src/assets/**/*", "config/**/*", ] - # Default options for the live reload server - DEFAULT_OPTIONS = { - port: 35729, - host: "localhost", - patterns: DEFAULT_WATCH_PATTERNS, - debounce: 1.second, - } - - # Returns whether the live reload server is running def self.running? : Bool - !@@server.nil? - end - - # Returns all connected WebSocket clients - def self.clients : Array(::HTTP::WebSocket) - @@clients + !@@watcher.nil? end - # Start the live reload server with the given options - def self.start(options = DEFAULT_OPTIONS) : Nil + def self.start(patterns = DEFAULT_WATCH_PATTERNS) : Nil return if running? spawn do - setup_server(options) - setup_watcher(options) + setup_watcher(patterns) end end - # Stop the live reload server if it's running def self.stop : Nil - if server = @@server - server.close - @@server = nil - end - if watcher = @@watcher watcher.stop @@watcher = nil end - - @@clients.clear - end - - # Trigger a reload on all connected clients - def self.trigger_reload : Nil - clients.each do |client| - begin - client.send("reload") - rescue - @@clients.delete(client) - end - end - end - - private def self.setup_server(options) - server = ::HTTP::Server.new do |context| - if context.request.resource == "/live_reload" - ws_handler = ::HTTP::WebSocketHandler.new do |ws, _ctx| - @@clients << ws - - # Keep connection alive with ping/pong - spawn do - loop do - begin - ws.ping - sleep 30 - rescue - break - end - end - end - - # Handle disconnection - ws.on_close do - @@clients.delete(ws) - end - end - - ws_handler.call(context) - end - end - - server.bind_tcp options[:host], options[:port] - server.listen - - @@server = server end - private def self.setup_watcher(options) - watcher = FileWatcher.new( - patterns: options[:patterns], - debounce: options[:debounce] - ) + private def self.setup_watcher(patterns) + watcher = FileWatcher.new(patterns: patterns) watcher.on_change do - trigger_reload + Marten::Handlers::LiveReload.broadcast("reload") end watcher.start From 8a2329887dd59d5457922bb18aa269b4bee3cdab Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 13:37:33 +1200 Subject: [PATCH 05/24] Remove duplicate live reload handler and merge logging improvements --- src/marten/handlers/live_reload 2.cr | 46 ---------------------------- 1 file changed, 46 deletions(-) delete mode 100644 src/marten/handlers/live_reload 2.cr diff --git a/src/marten/handlers/live_reload 2.cr b/src/marten/handlers/live_reload 2.cr deleted file mode 100644 index 7c7c049e8..000000000 --- a/src/marten/handlers/live_reload 2.cr +++ /dev/null @@ -1,46 +0,0 @@ -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 - Log.info { "[LiveReload] SSE client connected" } - channel = Channel(String).new - @@channels << channel - - streamed_content = Iterator.of do - begin - message = channel.receive - Log.info { "[LiveReload] Broadcasting: #{message}" } - "data: #{message}\n\n" - rescue - Log.info { "[LiveReload] SSE client disconnected" } - 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.each do |channel| - spawn { channel.send(message) } - end - end - - private def cleanup(channel) - @@channels.delete(channel) - end - end - end -end From 2b9b1e1a922ed3d9cfedbb8dbb47d95b20e49341 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 13:42:53 +1200 Subject: [PATCH 06/24] Update live_reload.cr --- src/marten/handlers/live_reload.cr | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr index c57dd021b..7c7c049e8 100644 --- a/src/marten/handlers/live_reload.cr +++ b/src/marten/handlers/live_reload.cr @@ -1,17 +1,22 @@ 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 + Log.info { "[LiveReload] SSE client connected" } channel = Channel(String).new @@channels << channel streamed_content = Iterator.of do begin message = channel.receive + Log.info { "[LiveReload] Broadcasting: #{message}" } "data: #{message}\n\n" rescue + Log.info { "[LiveReload] SSE client disconnected" } cleanup(channel) Iterator::Stop::INSTANCE end From da4648a61153a406014d107d796e18a8841a5171 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:05:42 +1200 Subject: [PATCH 07/24] Complete live reload implementation using SSE - Update middleware test to match SSE implementation - Add handler tests for SSE endpoint - Fix server tests for file watching - Update deprecated sleep calls to use Time::Span --- spec/marten/handlers/live_reload_spec.cr | 27 +++++ spec/marten/middleware/live_reload_spec.cr | 8 +- spec/marten/server/live_reload_spec.cr | 133 ++++++++------------- src/marten/server/file_watcher.cr | 2 +- src/marten/server/live_reload.cr | 13 +- 5 files changed, 86 insertions(+), 97 deletions(-) create mode 100644 spec/marten/handlers/live_reload_spec.cr 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 index 814dd97b9..cf2f8fb10 100644 --- a/spec/marten/middleware/live_reload_spec.cr +++ b/spec/marten/middleware/live_reload_spec.cr @@ -23,7 +23,7 @@ headers: ::HTTP::Headers{"Host" => "example.com"} -> { response } ) - response.content.should contain("new WebSocket('ws://localhost:35729/live_reload')") + response.content.should contain("new EventSource('/live-reload')") ensure Marten.settings.debug = original_settings end @@ -49,7 +49,7 @@ headers: ::HTTP::Headers{"Host" => "example.com"} -> { response } ) - response.content.should_not contain("WebSocket") + response.content.should_not contain("EventSource") ensure Marten.settings.debug = original_settings end @@ -75,7 +75,7 @@ headers: ::HTTP::Headers{"Host" => "example.com"} -> { response } ) - response.content.should_not contain("WebSocket") + response.content.should_not contain("EventSource") ensure Marten.settings.debug = original_settings end @@ -101,7 +101,7 @@ headers: ::HTTP::Headers{"Host" => "example.com", "X-Requested-With" => "XMLHttp -> { response } ) - response.content.should_not contain("WebSocket") + response.content.should_not contain("EventSource") ensure Marten.settings.debug = original_settings end diff --git a/spec/marten/server/live_reload_spec.cr b/spec/marten/server/live_reload_spec.cr index e2d4ff6c4..ecaa8bfb3 100644 --- a/spec/marten/server/live_reload_spec.cr +++ b/spec/marten/server/live_reload_spec.cr @@ -1,108 +1,71 @@ require "./spec_helper" -require "http/web_socket" module Marten describe Server::LiveReload do - describe ".running?" do - it "returns false by default" do - Marten::Server::LiveReload.running?.should be_false - end + 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 - Marten::Server::LiveReload.running?.should be_true - ensure - Marten::Server::LiveReload.stop + it "returns true when the server is started" do + begin + Marten::Server::LiveReload.start + # Give watcher time to start + sleep Time::Span.new(nanoseconds: 100_000_000) + Marten::Server::LiveReload.running?.should be_true + ensure + Marten::Server::LiveReload.stop + end end end - end - describe ".clients" do - it "returns an empty array by default" do - Marten::Server::LiveReload.clients.should be_empty - 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 - describe ".start" do - it "starts the WebSocket server with default options" 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 Time::Span.new(nanoseconds: 100_000_000) - # Test WebSocket connection -socket = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) - socket.on_message do |message| - message.should eq "reload" - socket.close + # Cleanup + File.delete("src/temp_test.cr") + ensure + Marten::Server::LiveReload.stop end - - # Trigger a reload - Marten::Server::LiveReload.trigger_reload - ensure - Marten::Server::LiveReload.stop end - end - it "starts the WebSocket server with custom options" do - begin - custom_port = 35730 - Marten::Server::LiveReload.start(port: custom_port) - Marten::Server::LiveReload.running?.should be_true - - # Test WebSocket connection -socket = HTTP::WebSocket::Client.new("localhost", "/live_reload", custom_port) - socket.on_message do |message| - message.should eq "reload" - socket.close + 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 Time::Span.new(nanoseconds: 100_000_000) + Marten::Server::LiveReload.running?.should be_true + + File.write("custom/temp_test.cr", "# test") + # Give the watcher time to detect the change + sleep Time::Span.new(nanoseconds: 100_000_000) + + # Cleanup + File.delete("custom/temp_test.cr") + Dir.delete("custom") + ensure + Marten::Server::LiveReload.stop end - - # Trigger a reload - Marten::Server::LiveReload.trigger_reload - ensure - Marten::Server::LiveReload.stop end end - end - - describe ".stop" do - it "stops the server and clears clients" do - Marten::Server::LiveReload.start - Marten::Server::LiveReload.stop - Marten::Server::LiveReload.running?.should be_false - Marten::Server::LiveReload.clients.should be_empty - end - end - - describe ".trigger_reload" do - it "sends reload message to all connected clients" do - messages_received = 0 - - begin + describe ".stop" do + it "stops the file watcher" do Marten::Server::LiveReload.start - - # Connect two clients - socket1 = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) - socket1.on_message { |_| messages_received += 1 } - - socket2 = HTTP::WebSocket::Client.new("localhost", "/live_reload", 35729) - socket2.on_message { |_| messages_received += 1 } - - # Allow time for connections to establish - sleep 0.1 - - # Trigger reload - Marten::Server::LiveReload.trigger_reload - - # Allow time for messages to be received - sleep 0.1 - - messages_received.should eq 2 - ensure Marten::Server::LiveReload.stop + + Marten::Server::LiveReload.running?.should be_false end end end end -end diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr index 9a36aad16..79b849fb6 100644 --- a/src/marten/server/file_watcher.cr +++ b/src/marten/server/file_watcher.cr @@ -26,7 +26,7 @@ module Marten spawn do while @running check_for_changes - sleep 0.5 + sleep(Time::Span.new(nanoseconds: 500_000_000)) # 0.5 seconds end end end diff --git a/src/marten/server/live_reload.cr b/src/marten/server/live_reload.cr index 1bad42ad3..6dddbe202 100644 --- a/src/marten/server/live_reload.cr +++ b/src/marten/server/live_reload.cr @@ -17,10 +17,7 @@ module Marten def self.start(patterns = DEFAULT_WATCH_PATTERNS) : Nil return if running? - - spawn do - setup_watcher(patterns) - end + setup_watcher(patterns) end def self.stop : Nil @@ -31,14 +28,16 @@ module Marten 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.start @@watcher = watcher + # Start watching asynchronously + spawn do + watcher.start + end end end end From c64316e03052c8145d8d028483d06877ea0cf1f6 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:14:42 +1200 Subject: [PATCH 08/24] Add workflow_dispatch trigger to workflows --- .github/workflows/qa.yml | 1 + .github/workflows/specs.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 3c0d882b7..507a430c5 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -1,6 +1,7 @@ name: QA on: + workflow_dispatch: push: branches: - "*" diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 36e46727a..be88c8608 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -1,6 +1,7 @@ name: Specs on: + workflow_dispatch: push: branches: - "*" From 2b9753c34002bcd6710e2df54ad6c74e54f1781a Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:23:30 +1200 Subject: [PATCH 09/24] Fix PR review comments: - Fix event handler reassignment in live reload script - Update middleware test to use call instead of chain --- spec/marten/middleware/live_reload_spec.cr | 5 +---- src/marten/middleware/live_reload.cr | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/spec/marten/middleware/live_reload_spec.cr b/spec/marten/middleware/live_reload_spec.cr index cf2f8fb10..934cea6d2 100644 --- a/spec/marten/middleware/live_reload_spec.cr +++ b/spec/marten/middleware/live_reload_spec.cr @@ -18,10 +18,7 @@ module Marten headers: ::HTTP::Headers{"Host" => "example.com"} ) ) - response = middleware.chain( - request, - -> { response } - ) + response = middleware.call(request, -> { response }) response.content.should contain("new EventSource('/live-reload')") ensure diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 53d0880c7..acd097efb 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -23,8 +23,8 @@ module Marten events.close(); setTimeout(() => { const newEvents = new EventSource('/live-reload'); - events.onmessage = events.onmessage; - events.onerror = events.onerror; + newEvents.onmessage = events.onmessage; + newEvents.onerror = events.onerror; }, 1000); }; })(); From 1c3f0d79f497cfb91af2ac4ceade43dd0b93e53a Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:25:37 +1200 Subject: [PATCH 10/24] Fix code formatting --- examples/config/settings/development.cr | 2 +- spec/marten/middleware/live_reload_spec.cr | 166 ++++++++++----------- spec/marten/server/live_reload_spec.cr | 2 +- src/marten/server.cr | 2 +- 4 files changed, 86 insertions(+), 86 deletions(-) diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr index f291946ba..b8c0e1cb3 100644 --- a/examples/config/settings/development.cr +++ b/examples/config/settings/development.cr @@ -4,7 +4,7 @@ Marten.configure :development do |config| # Enable live reload for development config.live_reload_enabled = true - + # Optional: customize file patterns to watch config.live_reload_patterns = [ "src/**/*.cr", diff --git a/spec/marten/middleware/live_reload_spec.cr b/spec/marten/middleware/live_reload_spec.cr index 934cea6d2..d60dd9c38 100644 --- a/spec/marten/middleware/live_reload_spec.cr +++ b/spec/marten/middleware/live_reload_spec.cr @@ -2,106 +2,106 @@ 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"} + 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 = middleware.call(request, -> { response }) - response.content.should contain("new EventSource('/live-reload')") - ensure - Marten.settings.debug = original_settings + response.content.should contain("new EventSource('/live-reload')") + ensure + Marten.settings.debug = original_settings + end 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 + 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") + 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"} + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.chain( + request, + -> { response } ) - ) - response = middleware.chain( - request, - -> { response } - ) - - response.content.should_not contain("EventSource") - ensure - Marten.settings.debug = original_settings + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end 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 + 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") + 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"} + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com"} + ) + ) + response = middleware.chain( + request, + -> { response } ) - ) - response = middleware.chain( - request, - -> { response } - ) - - response.content.should_not contain("EventSource") - ensure - Marten.settings.debug = original_settings + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end 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 + 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") + 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"} + request = Marten::HTTP::Request.new( + ::HTTP::Request.new( + method: "GET", + resource: "/", + headers: ::HTTP::Headers{"Host" => "example.com", "X-Requested-With" => "XMLHttpRequest"} + ) + ) + response = middleware.chain( + request, + -> { response } ) - ) - response = middleware.chain( - request, - -> { response } - ) - - response.content.should_not contain("EventSource") - ensure - Marten.settings.debug = original_settings + + response.content.should_not contain("EventSource") + ensure + Marten.settings.debug = original_settings + end end end end -end diff --git a/spec/marten/server/live_reload_spec.cr b/spec/marten/server/live_reload_spec.cr index ecaa8bfb3..38370c799 100644 --- a/spec/marten/server/live_reload_spec.cr +++ b/spec/marten/server/live_reload_spec.cr @@ -40,7 +40,7 @@ module Marten begin custom_patterns = ["custom/**/*.cr"] Dir.mkdir_p("custom") - + Marten::Server::LiveReload.start(custom_patterns) # Give watcher time to start sleep Time::Span.new(nanoseconds: 100_000_000) diff --git a/src/marten/server.cr b/src/marten/server.cr index 60438f524..eb69e924d 100644 --- a/src/marten/server.cr +++ b/src/marten/server.cr @@ -27,7 +27,7 @@ module Marten # Setups the server (TCP binding). def self.setup : Nil instance.bind_tcp(Marten.settings.host, Marten.settings.port, Marten.settings.port_reuse) - + # Start live reload if enabled in development mode if Marten.settings.debug? && Marten.settings.live_reload_enabled? LiveReload.start(Marten.settings.live_reload_patterns) From 8c9893b018623eedf7af7f6fc6b8124d78e2b1cb Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:29:30 +1200 Subject: [PATCH 11/24] Fix heredoc indentation to satisfy Ameba --- src/marten/middleware/live_reload.cr | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index acd097efb..958e91e44 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -13,22 +13,22 @@ module Marten return response unless insert_at script = <<-SCRIPT - + SCRIPT response.content = body.insert(insert_at, script) From e2917593dcf9e43917377bd4b27a3914fd28480e Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:30:28 +1200 Subject: [PATCH 12/24] Fix heredoc indentation with 2-space base indent --- src/marten/middleware/live_reload.cr | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 958e91e44..598395bfc 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -13,22 +13,22 @@ module Marten return response unless insert_at script = <<-SCRIPT - + SCRIPT response.content = body.insert(insert_at, script) From 21b7b290b476d529be2e773f2ccfd6354564729a Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 14:35:45 +1200 Subject: [PATCH 13/24] Fix heredoc indentation to match surrounding code --- src/marten/middleware/live_reload.cr | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 598395bfc..958e91e44 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -13,22 +13,22 @@ module Marten return response unless insert_at script = <<-SCRIPT - + SCRIPT response.content = body.insert(insert_at, script) From 2c5bdcc3be9aff10be3ea906dda179e59ec8b911 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:17:23 +1200 Subject: [PATCH 14/24] refactor: move live reload JS to separate file, fix test middleware calls --- spec/marten/middleware/live_reload_spec.cr | 15 +++------------ src/marten/middleware/live_reload.cr | 19 +------------------ src/marten/middleware/live_reload.js | 7 +++++++ 3 files changed, 11 insertions(+), 30 deletions(-) create mode 100644 src/marten/middleware/live_reload.js diff --git a/spec/marten/middleware/live_reload_spec.cr b/spec/marten/middleware/live_reload_spec.cr index d60dd9c38..4272779b3 100644 --- a/spec/marten/middleware/live_reload_spec.cr +++ b/spec/marten/middleware/live_reload_spec.cr @@ -41,10 +41,7 @@ module Marten headers: ::HTTP::Headers{"Host" => "example.com"} ) ) - response = middleware.chain( - request, - -> { response } - ) + response = middleware.call(request, -> { response }) response.content.should_not contain("EventSource") ensure @@ -67,10 +64,7 @@ module Marten headers: ::HTTP::Headers{"Host" => "example.com"} ) ) - response = middleware.chain( - request, - -> { response } - ) + response = middleware.call(request, -> { response }) response.content.should_not contain("EventSource") ensure @@ -93,10 +87,7 @@ module Marten headers: ::HTTP::Headers{"Host" => "example.com", "X-Requested-With" => "XMLHttpRequest"} ) ) - response = middleware.chain( - request, - -> { response } - ) + response = middleware.call(request, -> { response }) response.content.should_not contain("EventSource") ensure diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 958e91e44..72a5a5fb8 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -12,24 +12,7 @@ module Marten insert_at = body.downcase.rindex("") return response unless insert_at - script = <<-SCRIPT - - SCRIPT + script = {{ read_file "#{__DIR__}/live_reload.js" }} response.content = body.insert(insert_at, script) response diff --git a/src/marten/middleware/live_reload.js b/src/marten/middleware/live_reload.js new file mode 100644 index 000000000..23e45332d --- /dev/null +++ b/src/marten/middleware/live_reload.js @@ -0,0 +1,7 @@ + From b3ad69206345db362673e1eca0cfb3123b02fe2a Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:19:58 +1200 Subject: [PATCH 15/24] Update src/marten/handlers/live_reload.cr Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/marten/handlers/live_reload.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr index 7c7c049e8..af67ee4ec 100644 --- a/src/marten/handlers/live_reload.cr +++ b/src/marten/handlers/live_reload.cr @@ -15,7 +15,7 @@ module Marten message = channel.receive Log.info { "[LiveReload] Broadcasting: #{message}" } "data: #{message}\n\n" - rescue + rescue Channel::ClosedError Log.info { "[LiveReload] SSE client disconnected" } cleanup(channel) Iterator::Stop::INSTANCE From 1e258077b9273870bbe542d7cf803b582eb18453 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:21:35 +1200 Subject: [PATCH 16/24] fix: correct middleware reference in example configuration --- examples/config/settings/development.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr index b8c0e1cb3..73a917dcc 100644 --- a/examples/config/settings/development.cr +++ b/examples/config/settings/development.cr @@ -16,7 +16,7 @@ Marten.configure :development do |config| # Add the live reload middleware to the stack config.middleware = [ - Marten::Middleware::LiveReload.new, + Marten::LiveReload, # ... other middleware ... ] From 87611a6f8209132ff226ef6c773acde5151b9f1d Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:33:38 +1200 Subject: [PATCH 17/24] refactor: rename script to .html, remove debug logging --- src/marten/handlers/live_reload.cr | 3 --- src/marten/middleware/live_reload.cr | 2 +- src/marten/middleware/{live_reload.js => live_reload.html} | 0 3 files changed, 1 insertion(+), 4 deletions(-) rename src/marten/middleware/{live_reload.js => live_reload.html} (100%) diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr index af67ee4ec..c179ee23c 100644 --- a/src/marten/handlers/live_reload.cr +++ b/src/marten/handlers/live_reload.cr @@ -6,17 +6,14 @@ module Marten @@channels = [] of Channel(String) def get - Log.info { "[LiveReload] SSE client connected" } channel = Channel(String).new @@channels << channel streamed_content = Iterator.of do begin message = channel.receive - Log.info { "[LiveReload] Broadcasting: #{message}" } "data: #{message}\n\n" rescue Channel::ClosedError - Log.info { "[LiveReload] SSE client disconnected" } cleanup(channel) Iterator::Stop::INSTANCE end diff --git a/src/marten/middleware/live_reload.cr b/src/marten/middleware/live_reload.cr index 72a5a5fb8..c2bb54493 100644 --- a/src/marten/middleware/live_reload.cr +++ b/src/marten/middleware/live_reload.cr @@ -12,7 +12,7 @@ module Marten insert_at = body.downcase.rindex("") return response unless insert_at - script = {{ read_file "#{__DIR__}/live_reload.js" }} + script = {{ read_file "#{__DIR__}/live_reload.html" }} response.content = body.insert(insert_at, script) response diff --git a/src/marten/middleware/live_reload.js b/src/marten/middleware/live_reload.html similarity index 100% rename from src/marten/middleware/live_reload.js rename to src/marten/middleware/live_reload.html From 0c1359b7f9b82469f5709292081a51b49da4f865 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:41:45 +1200 Subject: [PATCH 18/24] revert: remove unnecessary workflow changes --- .github/workflows/qa.yml | 1 - .github/workflows/specs.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 507a430c5..3c0d882b7 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -1,7 +1,6 @@ name: QA on: - workflow_dispatch: push: branches: - "*" diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index be88c8608..36e46727a 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -1,7 +1,6 @@ name: Specs on: - workflow_dispatch: push: branches: - "*" From 5f1be3e77a588e17b8fb72b36262f1c5243f95d2 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:47:59 +1200 Subject: [PATCH 19/24] refactor: remove unnecessary require and pattern config from example --- examples/config/settings/development.cr | 9 --------- src/marten/server.cr | 2 -- 2 files changed, 11 deletions(-) diff --git a/examples/config/settings/development.cr b/examples/config/settings/development.cr index 73a917dcc..b5ad89598 100644 --- a/examples/config/settings/development.cr +++ b/examples/config/settings/development.cr @@ -5,15 +5,6 @@ Marten.configure :development do |config| # Enable live reload for development config.live_reload_enabled = true - # Optional: customize file patterns to watch - config.live_reload_patterns = [ - "src/**/*.cr", - "src/**/*.ecr", - "src/**/*.html", - "src/assets/**/*", - "config/**/*", - ] - # Add the live reload middleware to the stack config.middleware = [ Marten::LiveReload, diff --git a/src/marten/server.cr b/src/marten/server.cr index eb69e924d..9b09a1dd0 100644 --- a/src/marten/server.cr +++ b/src/marten/server.cr @@ -1,5 +1,3 @@ -require "http" - module Marten # Wrapper around the Marten server. module Server From e041cbd815797d089b1c33cd2f9da3590bce0e02 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:48:54 +1200 Subject: [PATCH 20/24] refactor: replace magic number with POLL_INTERVAL constant in file watcher --- src/marten/server/file_watcher.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr index 79b849fb6..5ebb51f5c 100644 --- a/src/marten/server/file_watcher.cr +++ b/src/marten/server/file_watcher.cr @@ -26,7 +26,7 @@ module Marten spawn do while @running check_for_changes - sleep(Time::Span.new(nanoseconds: 500_000_000)) # 0.5 seconds + sleep(POLL_INTERVAL) end end end @@ -36,6 +36,8 @@ module Marten @running = false end + private POLL_INTERVAL = 0.5.seconds + private def check_for_changes changed = false From 9dd95ec40466231952af5117f60393ca39143d16 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 15:54:49 +1200 Subject: [PATCH 21/24] refactor: handle specific file system exceptions in watcher --- src/marten/server/file_watcher.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr index 5ebb51f5c..dbc04b3c5 100644 --- a/src/marten/server/file_watcher.cr +++ b/src/marten/server/file_watcher.cr @@ -52,7 +52,8 @@ module Marten @timestamps[file] = current_time changed = true end - rescue + rescue ex : File::Error | Errno + # File may have been removed or is temporarily inaccessible; skip it next end end From 676b3609342213f468082986fd2e19fd75c47e57 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 16:01:04 +1200 Subject: [PATCH 22/24] Update src/marten/server/file_watcher.cr Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/marten/server/file_watcher.cr | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr index dbc04b3c5..771ad49af 100644 --- a/src/marten/server/file_watcher.cr +++ b/src/marten/server/file_watcher.cr @@ -23,11 +23,9 @@ module Marten return if @running @running = true - spawn do - while @running - check_for_changes - sleep(POLL_INTERVAL) - end + while @running + check_for_changes + sleep(POLL_INTERVAL) end end From 5d31a93e6f511c97014498c76c65057a80ef6dad Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 16:02:25 +1200 Subject: [PATCH 23/24] refactor: use readable sleep times, improve broadcast to handle closed channels --- spec/marten/server/live_reload_spec.cr | 8 ++++---- src/marten/handlers/live_reload.cr | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/spec/marten/server/live_reload_spec.cr b/spec/marten/server/live_reload_spec.cr index 38370c799..1783e80bd 100644 --- a/spec/marten/server/live_reload_spec.cr +++ b/spec/marten/server/live_reload_spec.cr @@ -11,7 +11,7 @@ module Marten begin Marten::Server::LiveReload.start # Give watcher time to start - sleep Time::Span.new(nanoseconds: 100_000_000) + sleep 0.1.seconds Marten::Server::LiveReload.running?.should be_true ensure Marten::Server::LiveReload.stop @@ -27,7 +27,7 @@ module Marten File.write("src/temp_test.cr", "# test") # Give the watcher time to detect the change - sleep Time::Span.new(nanoseconds: 100_000_000) + sleep 0.1.seconds # Cleanup File.delete("src/temp_test.cr") @@ -43,12 +43,12 @@ module Marten Marten::Server::LiveReload.start(custom_patterns) # Give watcher time to start - sleep Time::Span.new(nanoseconds: 100_000_000) + 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 Time::Span.new(nanoseconds: 100_000_000) + sleep 0.1.seconds # Cleanup File.delete("custom/temp_test.cr") diff --git a/src/marten/handlers/live_reload.cr b/src/marten/handlers/live_reload.cr index c179ee23c..dd4e1a3cf 100644 --- a/src/marten/handlers/live_reload.cr +++ b/src/marten/handlers/live_reload.cr @@ -30,8 +30,13 @@ module Marten end def self.broadcast(message : String) - @@channels.each do |channel| - spawn { channel.send(message) } + @@channels.reject! do |channel| + begin + channel.send(message) + false + rescue Channel::ClosedError + true + end end end From 007ae1bdfcff5ca995302b59a400a7e796b5ae29 Mon Sep 17 00:00:00 2001 From: John Jansen Date: Mon, 8 Sep 2025 16:24:48 +1200 Subject: [PATCH 24/24] fix: use valid exception types in FileWatcher --- src/marten/server/file_watcher.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marten/server/file_watcher.cr b/src/marten/server/file_watcher.cr index 771ad49af..f86f6f325 100644 --- a/src/marten/server/file_watcher.cr +++ b/src/marten/server/file_watcher.cr @@ -50,7 +50,7 @@ module Marten @timestamps[file] = current_time changed = true end - rescue ex : File::Error | Errno + rescue ex : File::Error | Exception # File may have been removed or is temporarily inaccessible; skip it next end