Coverage Summary
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; |