@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 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;