Module: BarefootJS::DevReload

Defined in:
lib/barefoot_js/dev_reload.rb

Overview

Framework-agnostic dev-only browser auto-reload for BarefootJS apps.

Ruby port of BarefootJS::DevReload (@barefootjs/perl), companion to barefoot build --watch in @barefootjs/cli. The CLI drops <dist>/.dev/build-id after every successful rebuild that changed output; a browser snippet subscribes to an SSE endpoint that emits event: reload when that file changes, so an editor save triggers an automatic reload.

require 'barefoot_js/dev_reload'

# Mount the SSE endpoint (dev only) as a plain Rack app, e.g. from
# config.ru's Rack::Builder:
map "#{BASE}/_bf/reload" do
run BarefootJS::DevReload.to_app(dist_dir: 'dist')
end

# And emit the browser snippet before </body> in your layout:
BarefootJS::DevReload.snippet("#{BASE}/_bf/reload")

This is a plain Rack app (not a Sinatra route) so it works the same way under any Rack-based host, mirroring the Perl module's PSGI-app shape.

Constant Summary collapse

DEV_SUBDIR =

Sentinel path contract with @barefootjs/cli (DEV_SENTINEL_SUBDIR / DEV_SENTINEL_FILENAME in packages/cli/src/lib/build.ts). Duplicated so this package avoids a runtime dep on the CLI — keep in sync with the CLI.

'.dev'
BUILD_ID_FILE =
'build-id'
SCROLL_STORAGE_KEY =
'__bf_devreload_scroll'
HEARTBEAT_S =

Heartbeat < any reasonable proxy/server idle timeout so a quiet connection doesn't get reaped between rebuilds.

5
POLL_S =

Polling instead of a filesystem-event gem (e.g. listen) keeps the runtime dependency-free. Sub-second latency is imperceptible next to a browser reload.

0.5

Class Method Summary collapse

Class Method Details

.build_id_path(dist_dir) ⇒ Object

/.dev/build-id — the sentinel barefoot build --watch rewrites.



45
46
47
# File 'lib/barefoot_js/dev_reload.rb', line 45

def self.build_id_path(dist_dir)
  File.join(dist_dir, DEV_SUBDIR, BUILD_ID_FILE)
end

.ensure_dev_dir(dist_dir) ⇒ Object

Ensure /.dev exists so the watcher can write the sentinel even if the server started first.



51
52
53
54
55
# File 'lib/barefoot_js/dev_reload.rb', line 51

def self.ensure_dev_dir(dist_dir)
  dev = File.join(dist_dir, DEV_SUBDIR)
  FileUtils.mkdir_p(dev)
  dev
end

.read_build_id(path) ⇒ Object



57
58
59
60
61
62
63
# File 'lib/barefoot_js/dev_reload.rb', line 57

def self.read_build_id(path)
  return '' unless File.file?(path)

  File.read(path, encoding: 'UTF-8').strip
rescue Errno::ENOENT
  ''
end

.snippet(endpoint) ⇒ Object

The browser snippet: a small IIFE — EventSource subscriber + scrollY preservation across reloads. Idempotent across duplicate mounts (the window.__bfDevReload guard). Returns a plain HTML string; callers embed it directly (ERB's own <%= never auto-escapes, so no mark_raw needed the way the Kolon/EP ports require).



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/barefoot_js/dev_reload.rb', line 70

def self.snippet(endpoint)
  ep = js_str(endpoint)
  sk = js_str(SCROLL_STORAGE_KEY)
  "<script>(function(){if(window.__bfDevReload)return;window.__bfDevReload=1;" \
    "try{var s=sessionStorage.getItem(#{sk});if(s){sessionStorage.removeItem(#{sk});" \
    "var y=parseInt(s,10);if(!isNaN(y)){var restore=function(){window.scrollTo(0,y)};" \
    "if(document.readyState==='loading'){addEventListener('DOMContentLoaded',restore,{once:true})}" \
    "else{restore()}}}}catch(e){}var es=new EventSource(#{ep});" \
    "es.addEventListener('reload',function(){try{sessionStorage.setItem(#{sk},String(window.scrollY))}" \
    "catch(e){}location.reload()});es.addEventListener('error',function(){})})();</script>"
end

.to_app(dist_dir: 'dist') ⇒ Object

A ready-made Rack app for the SSE endpoint. Streams event: reload whenever /.dev/build-id changes, with : hb heartbeats in between.

The response body is a lazy Enumerator — Puma (and any Rack server that doesn't buffer the body) writes each yielded chunk to the socket as it's produced, giving true incremental streaming without needing env['rack.hijack']. A write failure on a disconnected client surfaces as an exception raised back into the Enumerator's block at the y << call (Ruby re-raises consumer-side exceptions at the fiber's yield point) — the rescue below turns that into a clean loop exit, the same role Perl's local $SIG{PIPE} = 'IGNORE'; eval { ... } plays.

DevReload is automatically a no-op unless mounted, and should only be mounted in development — see app.rb's DEV guard.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/barefoot_js/dev_reload.rb', line 97

def self.to_app(dist_dir: 'dist')
  path = build_id_path(dist_dir)
  ensure_dev_dir(dist_dir)

  lambda do |env|
    last_event_id = (env['HTTP_LAST_EVENT_ID'] || '').strip

    body = Enumerator.new do |y|
      begin
        y << "retry: 1000\n\n"

        initial = read_build_id(path)
        last_sent = ''
        unless initial.empty?
          last_sent = initial
          # A stale Last-Event-ID means a build happened while the client
          # was disconnected — fire `reload` immediately so the missed
          # rebuild doesn't stay unpainted.
          event = (!last_event_id.empty? && last_event_id != initial) ? 'reload' : 'hello'
          y << "event: #{event}\nid: #{initial}\ndata: #{initial}\n\n"
        end

        since_hb = 0
        loop do
          sleep(POLL_S)
          id = read_build_id(path)
          if !id.empty? && id != last_sent
            last_sent = id
            since_hb = 0
            y << "event: reload\nid: #{id}\ndata: #{id}\n\n"
          else
            since_hb += POLL_S
            if since_hb >= HEARTBEAT_S
              since_hb = 0
              y << ": hb\n\n"
            end
          end
        end
      rescue IOError, Errno::EPIPE, Errno::ECONNRESET
        # Client disconnected — stop producing chunks.
      end
    end

    # Rack 3 requires lowercase header names (Rack::Lint enforces this in
    # development; Rack 2 accepted either case).
    [200, {
      'content-type' => 'text/event-stream',
      'cache-control' => 'no-cache, no-transform',
      'x-accel-buffering' => 'no',
    }, body]
  end
end