From 6e0a11237153e0a8309ae87a4865dc2b1719b42d Mon Sep 17 00:00:00 2001 From: eelco Date: Mon, 20 Jul 2026 22:08:27 +0200 Subject: [PATCH] Optional debounce + retry options --- README.md | 13 +++++++++++ lib/mata.rb | 4 ++-- lib/mata/agent.rb | 10 ++++++++- lib/mata/broadcaster.rb | 4 +++- lib/mata/client.js | 47 ++++++++++++++++++++++++++-------------- lib/mata/watch_tower.rb | 6 +++-- test/test_agent.rb | 8 +++++++ test/test_broadcaster.rb | 21 ++++++++++++++++++ test/test_helper.rb | 6 ++--- test/test_mata.rb | 6 ++--- test/test_watch_tower.rb | 14 +++++++++--- 11 files changed, 108 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 727acd9..9222587 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,19 @@ route do |r| end ``` + +### Options + +All options are optional. + +| Option | Default | Type | Description | +|---|---|---|---| +| `watch` | `[]` | Array of String | Directories to watch for changes | +| `skip` | `[]` | Array of String/Regexp | Paths to ignore | +| `debounce` | `300` | Integer | Debounce interval in milliseconds before broadcasting changes | +| `retry` | not set | Integer | SSE retry interval in milliseconds (not set = browser default ~3s, maximum clamped at 30s) | + + ## Used in This gem powers live reloading in [Perron](https://github.com/rails-designer/perron), a Rails-based static site generator. It can be enabled with `config.live_reload = true` in your Perron initializer. diff --git a/lib/mata.rb b/lib/mata.rb index 625879c..20e419a 100644 --- a/lib/mata.rb +++ b/lib/mata.rb @@ -14,8 +14,8 @@ def initialize(app, options = {}) return if ENV["MATA_DISABLED"] == "true" @watch_tower = WatchTower.new(options) - @broadcaster = Broadcaster.new - @agent = Agent.new + @broadcaster = Broadcaster.new(options) + @agent = Agent.new(options) @watch_tower.on_change { |files| @broadcaster.broadcast_to_all(files) } diff --git a/lib/mata/agent.rb b/lib/mata/agent.rb index d31b6f4..f4554e7 100644 --- a/lib/mata/agent.rb +++ b/lib/mata/agent.rb @@ -2,13 +2,21 @@ class Mata class Agent + def initialize(options = {}) + @retry = options[:retry] + end + def insert(status, headers, body) return [status, headers, body] unless html_response?(headers) content = extract(body) return [status, headers, [content]] unless content.include?("") - script_tag = '' + script_tag = if @retry + %() + else + '' + end modified_content = content.sub("", "#{script_tag}\n") headers["Content-Length"] = modified_content.bytesize.to_s if headers["Content-Length"] diff --git a/lib/mata/broadcaster.rb b/lib/mata/broadcaster.rb index cfa93ec..e9bf182 100644 --- a/lib/mata/broadcaster.rb +++ b/lib/mata/broadcaster.rb @@ -2,9 +2,10 @@ class Mata class Broadcaster - def initialize + def initialize(options = {}) @clients = [] @clients_mutex = Mutex.new + @retry = options[:retry] @cleanup_thread = cleanup_periodically end @@ -26,6 +27,7 @@ def establish_contact(env) end begin + stream << "retry: #{@retry}\n\n" if @retry stream << "data: {\"type\":\"connected\"}\n\n" rescue @clients_mutex.synchronize { @clients.delete(stream) } diff --git a/lib/mata/client.js b/lib/mata/client.js index c9a4dab..20f48f0 100644 --- a/lib/mata/client.js +++ b/lib/mata/client.js @@ -2,22 +2,41 @@ initMata(); function initMata() { - const eventSource = new EventSource("/__mata/events"); + const retryDelayBase = parseInt(document.currentScript.dataset.mataRetry) || 1000; + const maximumRetryDelay = 30000; + let retryDelay = retryDelayBase; - eventSource.onmessage = function(event) { - const data = JSON.parse(event.data); + connect(); - switch(data.type) { - case "reload": - morphPage(); + function connect() { + const eventSource = new EventSource("/__mata/events"); - break; - case "connected": - console.log("[Mata] Connected with DOM morphing"); + eventSource.onmessage = function(event) { + const data = JSON.parse(event.data); - break; - } - }; + switch(data.type) { + case "reload": + morphPage(); + + break; + case "connected": + retryDelay = retryDelayBase; + + console.log("[Mata] Connected with DOM morphing"); + + break; + } + }; + + eventSource.onerror = function() { + eventSource.close(); + + console.log(`[Mata] Connection lost, retrying in ${retryDelay}ms…`); + + setTimeout(connect, retryDelay); + retryDelay = Math.min(retryDelay * 2, maximumRetryDelay); + }; + } async function morphPage() { try { @@ -57,9 +76,5 @@ window.location.reload(); } } - - eventSource.onerror = function() { - console.log("[Mata] Connection lost, retrying…"); - }; } })(); diff --git a/lib/mata/watch_tower.rb b/lib/mata/watch_tower.rb index 9211e7a..e918bc1 100644 --- a/lib/mata/watch_tower.rb +++ b/lib/mata/watch_tower.rb @@ -5,6 +5,7 @@ class WatchTower def initialize(options) @watch_paths = Array(options[:watch] || []) @skip_paths = skipped_patterns(options[:skip] || options[:ignore] || []) + @debounce = options[:debounce] || 300 @on_change = nil @listener = nil @@ -42,8 +43,9 @@ def observe! @last_change_time = Time.now Thread.new do - sleep 0.15 - if Time.now - @last_change_time >= 0.15 + sleep(@debounce / 1000.0) + + if Time.now - @last_change_time >= (@debounce / 1000.0) @on_change&.call(modified + added + removed) end end diff --git a/test/test_agent.rb b/test/test_agent.rb index 1dad9b6..1905134 100644 --- a/test/test_agent.rb +++ b/test/test_agent.rb @@ -17,6 +17,14 @@ def test_inserts_script_into_html assert_includes body.first, "" end + def test_injects_data_attributes_when_retry_is_set + agent = Mata::Agent.new(retry: 5000) + html = "Content" + _, _, body = agent.insert(200, {"Content-Type" => "text/html"}, [html]) + + assert_includes body.first, 'data-mata-retry="5000"' + end + def test_skips_non_html_responses json = '{"data": "value"}' _, _, body = @agent.insert(200, {"Content-Type" => "application/json"}, [json]) diff --git a/test/test_broadcaster.rb b/test/test_broadcaster.rb index 087ba85..e224d8b 100644 --- a/test/test_broadcaster.rb +++ b/test/test_broadcaster.rb @@ -27,6 +27,27 @@ def test_handles_sse_connection assert_equal "keep-alive", headers["connection"] end + def test_sends_retry_directive_when_configured + broadcaster = Mata::Broadcaster.new(retry: 3000) + env = {"REQUEST_METHOD" => "GET"} + _, _, stream_proc = broadcaster.establish_contact(env) + + output = "" + stream_proc.call(output) + + assert_includes output, "retry: 3000\n\n" + end + + def test_omits_retry_directive_when_not_configured + env = {"REQUEST_METHOD" => "GET"} + _, _, stream_proc = @broadcaster.establish_contact(env) + + output = "" + stream_proc.call(output) + + refute_includes output, "retry:" + end + def test_rejects_non_get_sse_requests env = {"REQUEST_METHOD" => "POST"} status, _, _ = @broadcaster.establish_contact(env) diff --git a/test/test_helper.rb b/test/test_helper.rb index 7658418..d42b622 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,15 +5,15 @@ module TestHelpers def setup_temp_directory - @temp_dir = Dir.mktmpdir + @watch_dir = Dir.mktmpdir end def teardown_temp_directory - FileUtils.rm_rf(@temp_dir) if @temp_dir + FileUtils.rm_rf(@watch_dir) if @watch_dir end def create_test_file(name, content = "test content") - File.join(@temp_dir, name).tap { File.write(it, content) } + File.join(@watch_dir, name).tap { File.write(it, content) } end def wait_for_file_watcher(seconds = 0.3) diff --git a/test/test_mata.rb b/test/test_mata.rb index a7e1a35..8e600b5 100644 --- a/test/test_mata.rb +++ b/test/test_mata.rb @@ -8,7 +8,7 @@ def setup setup_temp_directory @app = lambda { |env| [200, {"Content-Type" => "text/html"}, ["Hello"]] } - @mata = Mata.new(@app, watch: [@temp_dir], skip: ["tmp"]) + @mata = Mata.new(@app, watch: [@watch_dir], skip: ["tmp"], debounce: 300, retry: 3000) end def teardown @@ -24,7 +24,7 @@ def app def test_injects_script_into_html_responses get "/" - assert_includes last_response.body, '' + assert_includes last_response.body, '' end def test_serves_client_script @@ -46,6 +46,6 @@ def test_passes_through_other_requests get "/other" assert_includes last_response.body, "Hello" - assert_includes last_response.body, '' + assert_includes last_response.body, '' end end diff --git a/test/test_watch_tower.rb b/test/test_watch_tower.rb index 4de6caa..f3a4817 100644 --- a/test/test_watch_tower.rb +++ b/test/test_watch_tower.rb @@ -5,8 +5,8 @@ class TestWatchTower < Minitest::Test include TestHelpers def setup - @temp_dir = Dir.mktmpdir - @watch_tower = Mata::WatchTower.new(watch: [@temp_dir]) + @watch_dir = Dir.mktmpdir + @watch_tower = Mata::WatchTower.new(watch: [@watch_dir]) @changes = [] @watch_tower.on_change { |files| @changes.concat(files) } @@ -15,7 +15,15 @@ def setup def teardown @watch_tower.shutdown - FileUtils.rm_rf(@temp_dir) + FileUtils.rm_rf(@watch_dir) + end + + def test_accepts_custom_debounce + watch_tower = Mata::WatchTower.new(watch: [@watch_dir], debounce: 500) + + assert_equal 500, watch_tower.instance_variable_get(:@debounce) + ensure + watch_tower.shutdown end def test_on_change_callback_fires