File Coverage

blib/lib/BarefootJS/DevReload.pm
Criterion Covered Total %
statement 81 94 86.1
branch 10 16 62.5
condition 5 12 41.6
subroutine 12 12 100.0
pod 0 5 0.0
total 108 139 77.7


line stmt bran cond sub pod time code
1             package BarefootJS::DevReload;
2             our $VERSION = "0.15.1";
3 1     1   173505 use strict;
  1         2  
  1         27  
4 1     1   4 use warnings;
  1         2  
  1         46  
5 1     1   5 use feature 'signatures';
  1         1  
  1         119  
6 1     1   5 no warnings 'experimental::signatures';
  1         2  
  1         37  
7              
8 1     1   3 use File::Spec;
  1         1  
  1         941  
9              
10             =head1 NAME
11              
12             BarefootJS::DevReload - Framework-agnostic dev-only browser auto-reload for BarefootJS apps
13              
14             =head1 SYNOPSIS
15              
16             # Plain PSGI / Plack (e.g. the Text::Xslate backend)
17             use BarefootJS::DevReload;
18              
19             # Mount the SSE endpoint (dev only):
20             my $reload = BarefootJS::DevReload->to_app(dist_dir => 'dist');
21             # ... route '/_bf/reload' => $reload ...
22              
23             # And emit the browser snippet before in your layout:
24             BarefootJS::DevReload->snippet('/_bf/reload');
25              
26             =head1 DESCRIPTION
27              
28             Companion to C in C<@barefootjs/cli>. The CLI drops
29             C<< /.dev/build-id >> after every successful rebuild that changed output;
30             a browser snippet subscribes to an SSE endpoint that emits C<< event: reload >>
31             when that file changes, so an editor save triggers an automatic reload.
32              
33             This module holds the engine-agnostic pieces — the browser snippet, the
34             build-id reader, and a ready-made PSGI streaming app for the SSE endpoint — so
35             both L (Mojo streaming) and plain
36             PSGI/Plack hosts (the Text::Xslate backend) share one implementation.
37              
38             =cut
39              
40             # Sentinel path contract with @barefootjs/cli (DEV_SENTINEL_SUBDIR /
41             # DEV_SENTINEL_FILENAME in packages/cli/src/lib/build.ts). Duplicated so this
42             # package avoids a runtime dep on the CLI — keep in sync with the CLI.
43             my $DEV_SUBDIR = '.dev';
44             my $BUILD_ID_FILE = 'build-id';
45              
46             our $SCROLL_STORAGE_KEY = '__bf_devreload_scroll';
47              
48             # Heartbeat < any reasonable proxy/IOLoop idle timeout so a quiet connection
49             # doesn't get reaped between rebuilds.
50             our $HEARTBEAT_S = 5;
51              
52             # Polling instead of Linux::Inotify2 / Mac::FSEvents keeps the runtime
53             # dependency-free. Sub-second latency is imperceptible next to browser reload.
54             our $POLL_S = 0.5;
55              
56             # /.dev/build-id — the sentinel `barefoot build --watch` rewrites.
57 2     2 0 12640 sub build_id_path ($class, $dist_dir) {
  2         6  
  2         4  
  2         4  
58 2         52 return File::Spec->catfile($dist_dir, $DEV_SUBDIR, $BUILD_ID_FILE);
59             }
60              
61             # Ensure /.dev exists so the watcher can write the sentinel even if the
62             # server started first. Returns the dir.
63 2     2 0 3 sub ensure_dev_dir ($class, $dist_dir) {
  2         2  
  2         3  
  2         2  
64 2         10 my $dev = File::Spec->catdir($dist_dir, $DEV_SUBDIR);
65 2 100       166 mkdir $dev unless -d $dev;
66 2         7 return $dev;
67             }
68              
69 4     4 0 508 sub read_build_id ($class, $path) {
  4         8  
  4         6  
  4         5  
70 4 100       132 return '' unless -f $path;
71 3 50       115 open my $fh, '<', $path or return '';
72 3         15 local $/;
73 3         122 my $content = <$fh>;
74 3         34 close $fh;
75 3   50     10 $content //= '';
76 3         24 $content =~ s/^\s+|\s+$//g;
77 3         31 return $content;
78             }
79              
80             # The browser snippet: a small IIFE — EventSource subscriber + scrollY
81             # preservation across reloads. Idempotent across duplicate mounts (the
82             # window.__bfDevReload guard). Returns a plain HTML string; callers mark it raw
83             # for their template engine.
84 1     1 0 145692 sub snippet ($class, $endpoint) {
  1         5  
  1         2  
  1         2  
85 1         4 my $ep = _js_str($endpoint);
86 1         3 my $sk = _js_str($SCROLL_STORAGE_KEY);
87 1         5 return qq{};
88             }
89              
90             # A ready-made PSGI app for the SSE endpoint. Streams `event: reload` whenever
91             # /.dev/build-id changes, with `: hb` heartbeats in between.
92             #
93             # Implemented with the PSGI streaming interface and a blocking poll loop, so it
94             # holds one worker per open connection for the connection's lifetime — run it
95             # under a prefork PSGI server (Starman / Starlet) in dev, which is the natural
96             # choice for an app that also streams (e.g. an AI-chat SSE route). DevReload is
97             # automatically a no-op unless you mount it, and you should only mount it in
98             # development.
99 1     1 0 2 sub to_app ($class, %opts) {
  1         2  
  1         2  
  1         2  
100 1   50     4 my $dist_dir = $opts{dist_dir} // 'dist';
101 1         2 my $build_id_path = $class->build_id_path($dist_dir);
102 1         3 $class->ensure_dev_dir($dist_dir);
103              
104 3     3   1607 return sub ($env) {
  3         5  
  3         4  
105             return [500, ['Content-Type' => 'text/plain'], ['DevReload needs a psgi.streaming server']]
106 3 100       14 unless $env->{'psgi.streaming'};
107              
108 2   50     8 my $last_event_id = $env->{HTTP_LAST_EVENT_ID} // '';
109 2         7 $last_event_id =~ s/^\s+|\s+$//g;
110              
111             return sub ($responder) {
112 2         9 my $writer = $responder->([
113             200,
114             [
115             'Content-Type' => 'text/event-stream',
116             'Cache-Control' => 'no-cache, no-transform',
117             'X-Accel-Buffering' => 'no',
118             ],
119             ]);
120              
121             # A write to a disconnected client throws (SIGPIPE/EPIPE); the eval
122             # turns that into a clean loop exit.
123 2         38 local $SIG{PIPE} = 'IGNORE';
124 2         3 eval {
125 2         7 $writer->write("retry: 1000\n\n");
126              
127 2         20 my $initial = $class->read_build_id($build_id_path);
128 2         5 my $last_sent = '';
129 2 50       7 if (length $initial) {
130 2         3 $last_sent = $initial;
131             # A stale Last-Event-ID means a build happened while the
132             # client was disconnected — fire `reload` immediately so the
133             # missed rebuild doesn't stay unpainted.
134 2 100 66     13 my $event = (length $last_event_id && $last_event_id ne $initial)
135             ? 'reload' : 'hello';
136 2         13 $writer->write("event: $event\nid: $initial\ndata: $initial\n\n");
137             }
138              
139 0         0 my $since_hb = 0;
140 0         0 while (1) {
141 0         0 select undef, undef, undef, $POLL_S;
142 0         0 my $id = $class->read_build_id($build_id_path);
143 0 0 0     0 if (length $id && $id ne $last_sent) {
144 0         0 $last_sent = $id;
145 0         0 $since_hb = 0;
146 0         0 $writer->write("event: reload\nid: $id\ndata: $id\n\n");
147             }
148             else {
149 0         0 $since_hb += $POLL_S;
150 0 0       0 if ($since_hb >= $HEARTBEAT_S) {
151 0         0 $since_hb = 0;
152 0         0 $writer->write(": hb\n\n");
153             }
154             }
155             }
156 0         0 1;
157             };
158 2         29 $writer->close;
159 2         33 };
160 1         7 };
161             }
162              
163 2     2   2 sub _js_str ($s) {
  2         3  
  2         2  
164             # Minimal JS string escape for the handful of characters that can appear in
165             # a URL path or storage key. Good enough for package-internal + trusted
166             # operator-supplied strings; never interpolate untrusted input here.
167 2         3 my $t = $s;
168 2         3 $t =~ s/\\/\\\\/g;
169 2         4 $t =~ s/"/\\"/g;
170 2         2 $t =~ s/\n/\\n/g;
171 2         3 $t =~ s/\r/\\r/g;
172 2         3 return qq{"$t"};
173             }
174              
175             1;