@barefootjs/perl 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/lib/BarefootJS/DevReload.pm +175 -0
- package/lib/BarefootJS.pm +1219 -0
- package/package.json +25 -0
- package/t/controller_cycle.t +79 -0
- package/t/dev_reload.t +58 -0
- package/t/spread_attrs.t +134 -0
- package/t/template_primitives.t +509 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @barefootjs/perl
|
|
2
|
+
|
|
3
|
+
The engine-agnostic **Perl runtime** for [BarefootJS](https://barefootjs.dev) —
|
|
4
|
+
the `BarefootJS` module (`BarefootJS.pm`) that compiled marked templates call as
|
|
5
|
+
the `bf` helper (`bf->json(...)`, `bf->spread_attrs(...)`, the array/string
|
|
6
|
+
method helpers, hydration markers, child-component rendering).
|
|
7
|
+
|
|
8
|
+
This package depends **only on core Perl** (subroutine signatures + a tiny
|
|
9
|
+
hand-rolled accessor base). Everything that depends on *how* a template is
|
|
10
|
+
rendered — JSON marshalling, raw-string marking, JSX-children materialisation,
|
|
11
|
+
and named-template rendering — is delegated to a pluggable **backend**, so the
|
|
12
|
+
same runtime can drive any Perl template engine / web framework.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
JSX → IR → (compile-time adapter) → marked template ─┐
|
|
16
|
+
├─► rendered by the host's
|
|
17
|
+
BarefootJS runtime ── backend ── template engine ───┘ template engine
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Backend contract
|
|
21
|
+
|
|
22
|
+
A backend implements four methods:
|
|
23
|
+
|
|
24
|
+
| Method | Purpose |
|
|
25
|
+
|--------|---------|
|
|
26
|
+
| `encode_json($data)` | JSON-encode a value (injectable; e.g. `Cpanel::JSON::XS`) |
|
|
27
|
+
| `mark_raw($str)` | Mark a string so the engine emits it without re-escaping |
|
|
28
|
+
| `materialize($value)` | Resolve a captured-children ref to a string |
|
|
29
|
+
| `render_named($name, $bf, \%vars)` | Render a named template with `$bf` bound |
|
|
30
|
+
|
|
31
|
+
Inject a backend with `BarefootJS->new($c, { backend => $b })`.
|
|
32
|
+
|
|
33
|
+
## Reference implementation
|
|
34
|
+
|
|
35
|
+
[`@barefootjs/mojolicious`](../adapter-mojolicious) provides
|
|
36
|
+
`BarefootJS::Backend::Mojo` (Mojolicious / `Mojo::Template`) plus the
|
|
37
|
+
`Mojolicious::Plugin::BarefootJS` binding and the compile-time adapter that
|
|
38
|
+
emits Mojolicious Embedded Perl (`.html.ep`).
|
|
39
|
+
|
|
40
|
+
## CPAN
|
|
41
|
+
|
|
42
|
+
The CPAN distribution name is `BarefootJS`. The npm package ships the same
|
|
43
|
+
`lib/` so the monorepo integrations can consume it without a separate install.
|
|
44
|
+
|
|
45
|
+
## Tests
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
prove -lv t/
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The tests run with core Perl only (no Mojolicious required) by injecting a
|
|
52
|
+
pure-Perl `JSON::PP` backend.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
package BarefootJS::DevReload;
|
|
2
|
+
our $VERSION = "0.01";
|
|
3
|
+
use strict;
|
|
4
|
+
use warnings;
|
|
5
|
+
use feature 'signatures';
|
|
6
|
+
no warnings 'experimental::signatures';
|
|
7
|
+
|
|
8
|
+
use File::Spec;
|
|
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 </body> in your layout:
|
|
24
|
+
BarefootJS::DevReload->snippet('/_bf/reload');
|
|
25
|
+
|
|
26
|
+
=head1 DESCRIPTION
|
|
27
|
+
|
|
28
|
+
Companion to C<barefoot build --watch> in C<@barefootjs/cli>. The CLI drops
|
|
29
|
+
C<< <dist>/.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<Mojolicious::Plugin::BarefootJS::DevReload> (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
|
+
# <dist>/.dev/build-id — the sentinel `barefoot build --watch` rewrites.
|
|
57
|
+
sub build_id_path ($class, $dist_dir) {
|
|
58
|
+
return File::Spec->catfile($dist_dir, $DEV_SUBDIR, $BUILD_ID_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Ensure <dist>/.dev exists so the watcher can write the sentinel even if the
|
|
62
|
+
# server started first. Returns the dir.
|
|
63
|
+
sub ensure_dev_dir ($class, $dist_dir) {
|
|
64
|
+
my $dev = File::Spec->catdir($dist_dir, $DEV_SUBDIR);
|
|
65
|
+
mkdir $dev unless -d $dev;
|
|
66
|
+
return $dev;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sub read_build_id ($class, $path) {
|
|
70
|
+
return '' unless -f $path;
|
|
71
|
+
open my $fh, '<', $path or return '';
|
|
72
|
+
local $/;
|
|
73
|
+
my $content = <$fh>;
|
|
74
|
+
close $fh;
|
|
75
|
+
$content //= '';
|
|
76
|
+
$content =~ s/^\s+|\s+$//g;
|
|
77
|
+
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
|
+
sub snippet ($class, $endpoint) {
|
|
85
|
+
my $ep = _js_str($endpoint);
|
|
86
|
+
my $sk = _js_str($SCROLL_STORAGE_KEY);
|
|
87
|
+
return qq{<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>};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# A ready-made PSGI app for the SSE endpoint. Streams `event: reload` whenever
|
|
91
|
+
# <dist>/.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
|
+
sub to_app ($class, %opts) {
|
|
100
|
+
my $dist_dir = $opts{dist_dir} // 'dist';
|
|
101
|
+
my $build_id_path = $class->build_id_path($dist_dir);
|
|
102
|
+
$class->ensure_dev_dir($dist_dir);
|
|
103
|
+
|
|
104
|
+
return sub ($env) {
|
|
105
|
+
return [500, ['Content-Type' => 'text/plain'], ['DevReload needs a psgi.streaming server']]
|
|
106
|
+
unless $env->{'psgi.streaming'};
|
|
107
|
+
|
|
108
|
+
my $last_event_id = $env->{HTTP_LAST_EVENT_ID} // '';
|
|
109
|
+
$last_event_id =~ s/^\s+|\s+$//g;
|
|
110
|
+
|
|
111
|
+
return sub ($responder) {
|
|
112
|
+
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
|
+
local $SIG{PIPE} = 'IGNORE';
|
|
124
|
+
eval {
|
|
125
|
+
$writer->write("retry: 1000\n\n");
|
|
126
|
+
|
|
127
|
+
my $initial = $class->read_build_id($build_id_path);
|
|
128
|
+
my $last_sent = '';
|
|
129
|
+
if (length $initial) {
|
|
130
|
+
$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
|
+
my $event = (length $last_event_id && $last_event_id ne $initial)
|
|
135
|
+
? 'reload' : 'hello';
|
|
136
|
+
$writer->write("event: $event\nid: $initial\ndata: $initial\n\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
my $since_hb = 0;
|
|
140
|
+
while (1) {
|
|
141
|
+
select undef, undef, undef, $POLL_S;
|
|
142
|
+
my $id = $class->read_build_id($build_id_path);
|
|
143
|
+
if (length $id && $id ne $last_sent) {
|
|
144
|
+
$last_sent = $id;
|
|
145
|
+
$since_hb = 0;
|
|
146
|
+
$writer->write("event: reload\nid: $id\ndata: $id\n\n");
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
$since_hb += $POLL_S;
|
|
150
|
+
if ($since_hb >= $HEARTBEAT_S) {
|
|
151
|
+
$since_hb = 0;
|
|
152
|
+
$writer->write(": hb\n\n");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
1;
|
|
157
|
+
};
|
|
158
|
+
$writer->close;
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
sub _js_str ($s) {
|
|
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
|
+
my $t = $s;
|
|
168
|
+
$t =~ s/\\/\\\\/g;
|
|
169
|
+
$t =~ s/"/\\"/g;
|
|
170
|
+
$t =~ s/\n/\\n/g;
|
|
171
|
+
$t =~ s/\r/\\r/g;
|
|
172
|
+
return qq{"$t"};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
1;
|