Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3a3bdb0
Add live reload functionality for development
johnjansen Sep 5, 2025
ec89872
feat: Implement live reload middleware and server components
johnjansen Sep 5, 2025
e7ea2eb
Add live reload middleware
johnjansen Sep 8, 2025
0759ab4
Add live reload implementation without test project
johnjansen Sep 8, 2025
5fc94ab
Merge remote-tracking branch 'upstream/main' into feature/dev-live-re…
johnjansen Sep 8, 2025
8a23298
Remove duplicate live reload handler and merge logging improvements
johnjansen Sep 8, 2025
2b9b1e1
Update live_reload.cr
johnjansen Sep 8, 2025
da4648a
Complete live reload implementation using SSE
johnjansen Sep 8, 2025
c64316e
Add workflow_dispatch trigger to workflows
johnjansen Sep 8, 2025
2b9753c
Fix PR review comments:
johnjansen Sep 8, 2025
1c3f0d7
Fix code formatting
johnjansen Sep 8, 2025
8c9893b
Fix heredoc indentation to satisfy Ameba
johnjansen Sep 8, 2025
e291759
Fix heredoc indentation with 2-space base indent
johnjansen Sep 8, 2025
21b7b29
Fix heredoc indentation to match surrounding code
johnjansen Sep 8, 2025
2c5bdcc
refactor: move live reload JS to separate file, fix test middleware c…
johnjansen Sep 8, 2025
b3ad692
Update src/marten/handlers/live_reload.cr
johnjansen Sep 8, 2025
1e25807
fix: correct middleware reference in example configuration
johnjansen Sep 8, 2025
87611a6
refactor: rename script to .html, remove debug logging
johnjansen Sep 8, 2025
0c1359b
revert: remove unnecessary workflow changes
johnjansen Sep 8, 2025
5f1be3e
refactor: remove unnecessary require and pattern config from example
johnjansen Sep 8, 2025
e041cbd
refactor: replace magic number with POLL_INTERVAL constant in file wa…
johnjansen Sep 8, 2025
9dd95ec
refactor: handle specific file system exceptions in watcher
johnjansen Sep 8, 2025
676b360
Update src/marten/server/file_watcher.cr
johnjansen Sep 8, 2025
5d31a93
refactor: use readable sleep times, improve broadcast to handle close…
johnjansen Sep 8, 2025
007ae1b
fix: use valid exception types in FileWatcher
johnjansen Sep 8, 2025
3a98ed4
Merge remote-tracking branch 'upstream/main' into jj-monitor-merge-ma…
May 20, 2026
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
15 changes: 15 additions & 0 deletions examples/config/settings/development.cr
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions spec/marten/handlers/live_reload_spec.cr
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions spec/marten/middleware/live_reload_spec.cr
Original file line number Diff line number Diff line change
@@ -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("<html><body>Test</body></html>", 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("<html><body>Test</body></html>", 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("<html><body>Test</body></html>", 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
71 changes: 71 additions & 0 deletions spec/marten/server/live_reload_spec.cr
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/marten/conf/global_settings.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions src/marten/handlers/live_reload.cr
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions src/marten/middleware/live_reload.cr
Original file line number Diff line number Diff line change
@@ -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 </body> tag (case-insensitive)
insert_at = body.downcase.rindex("</body>")
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
7 changes: 7 additions & 0 deletions src/marten/middleware/live_reload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
(function() {
if (!('EventSource' in window)) return;
const es = new EventSource('/live-reload');
es.onmessage = function() { window.location.reload(); };
})();
</script>
6 changes: 6 additions & 0 deletions src/marten/server.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,6 +44,7 @@ module Marten
# Stops the server.
def self.stop : Nil
instance.close
LiveReload.stop if LiveReload.running?
end
end
end
Loading
Loading