phlex-reactive 0.13.4 → 0.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1050 -630
- data/README.md +561 -14
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3690 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +71 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +17 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- metadata +38 -1
|
@@ -16,6 +16,8 @@ module Phlex
|
|
|
16
16
|
# default-deny boundary; the worst it does is a throwaway sign→verify round
|
|
17
17
|
# trip and (for the component checks) iterate the loaded Streamable registry.
|
|
18
18
|
class Doctor
|
|
19
|
+
include FiberIsolation
|
|
20
|
+
|
|
19
21
|
# The result of one check: a status (:ok/:fail/:unknown), a human message,
|
|
20
22
|
# and (on anything but :ok) a fix line telling the adopter what to do. A
|
|
21
23
|
# plain value object (not Data) so it takes positional status/message plus
|
|
@@ -70,14 +72,19 @@ module Phlex
|
|
|
70
72
|
[
|
|
71
73
|
route_check,
|
|
72
74
|
defer_route_check,
|
|
75
|
+
fragment_route_check,
|
|
76
|
+
fragment_path_meta_check,
|
|
73
77
|
stimulus_check,
|
|
74
78
|
csrf_check,
|
|
75
79
|
verifier_check,
|
|
76
80
|
base_controller_check,
|
|
81
|
+
fiber_isolation_check,
|
|
77
82
|
action_check(components),
|
|
78
83
|
id_check(components),
|
|
79
|
-
authorization_check(components)
|
|
80
|
-
|
|
84
|
+
authorization_check(components),
|
|
85
|
+
dormant_check(components),
|
|
86
|
+
*early_pin_check(components)
|
|
87
|
+
].compact
|
|
81
88
|
end
|
|
82
89
|
|
|
83
90
|
# --- individual checks ------------------------------------------------
|
|
@@ -97,6 +104,45 @@ module Phlex
|
|
|
97
104
|
path_check(Phlex::Reactive.defer_path, :defer_route, "Phlex::Reactive.defer_path")
|
|
98
105
|
end
|
|
99
106
|
|
|
107
|
+
# The cacheable-fragment GET endpoint (issue #277): a host GET catch-all
|
|
108
|
+
# that shadows it makes every `reactive_lazy cache:` fetch load the host's
|
|
109
|
+
# fallback page instead of the fragment.
|
|
110
|
+
def fragment_route_check
|
|
111
|
+
path = "#{Phlex::Reactive.fragment_path}/:id"
|
|
112
|
+
if Phlex::Reactive.fragment_route_ok?
|
|
113
|
+
Check.new(:ok, "GET #{path} routes to phlex/reactive/fragments", name: :fragment_route)
|
|
114
|
+
else
|
|
115
|
+
Check.new(:fail, "GET #{path} does not resolve to phlex/reactive/fragments", name: :fragment_route,
|
|
116
|
+
fix: "A host catch-all route (get \"*path\", ...) likely shadows it. Exempt " \
|
|
117
|
+
"#{Phlex::Reactive.fragment_path.delete_prefix("/")} from the catch-all, or set " \
|
|
118
|
+
"Phlex::Reactive.fragment_path to an unshadowed path.")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A custom fragment_path the client was never told about (issue #277): the
|
|
123
|
+
# client only fetches a fragment URL under the path it knows — the meta
|
|
124
|
+
# tag, else the default — so every `reactive_lazy cache:` shell would be
|
|
125
|
+
# refused (reactive:error, the shell never loads). Only checked when the
|
|
126
|
+
# path was changed; nil (no check) otherwise.
|
|
127
|
+
def fragment_path_meta_check
|
|
128
|
+
path = Phlex::Reactive.fragment_path
|
|
129
|
+
return if path == "/reactive/fragment"
|
|
130
|
+
|
|
131
|
+
# A file must name the meta AND supply this path — literally, or from
|
|
132
|
+
# the setting (`Phlex::Reactive.fragment_path`). A tag left behind with
|
|
133
|
+
# an old path is as broken as none. Whether it sits in <head> (the only
|
|
134
|
+
# place the client reads it) can't be told from source; the fix says so.
|
|
135
|
+
if layout_references?("phlex-reactive-fragment-path") { it.include?(path) || it.include?("fragment_path") }
|
|
136
|
+
Check.new(:ok, "phlex-reactive-fragment-path meta found for #{path} (it must be in <head>)",
|
|
137
|
+
name: :fragment_path_meta)
|
|
138
|
+
else
|
|
139
|
+
Check.new(:fail, "Phlex::Reactive.fragment_path is #{path} but no layout renders its meta tag with that path",
|
|
140
|
+
name: :fragment_path_meta,
|
|
141
|
+
fix: "Add <meta name=\"phlex-reactive-fragment-path\" content=\"#{path}\"> to your layout's " \
|
|
142
|
+
"<head> — the client refuses a fragment URL outside the path it knows.")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
100
146
|
# Shared body for the two endpoint-route checks: both POST to the gem's
|
|
101
147
|
# ActionsController, so action_route_ok? answers for either path.
|
|
102
148
|
def path_check(path, name, setting)
|
|
@@ -212,6 +258,42 @@ module Phlex
|
|
|
212
258
|
"`skip_verify_authorized`. See the Debugging & tooling docs page.")
|
|
213
259
|
end
|
|
214
260
|
|
|
261
|
+
# Dormant roots (issue #274): how many components declare `reactive_dormant`
|
|
262
|
+
# and whether a Stimulus entrypoint imports phlex/reactive/early — the
|
|
263
|
+
# module that wakes them (without it a dormant root never mounts). No line
|
|
264
|
+
# at all when nothing is dormant. ADVISORY when the import isn't found: it
|
|
265
|
+
# may live in a file the doctor doesn't scan. A per-render
|
|
266
|
+
# reactive_root(dormant: true) is invisible here (it isn't a declaration).
|
|
267
|
+
def dormant_check(components, early: imports_early?)
|
|
268
|
+
dormant = dormant_components(components)
|
|
269
|
+
return if dormant.empty?
|
|
270
|
+
|
|
271
|
+
label = "#{dormant.size} dormant #{dormant.one? ? "component" : "components"} (#{dormant.map(&:name).join(", ")})"
|
|
272
|
+
return Check.new(:ok, "#{label}; phlex/reactive/early is imported#{early_location(early)}", name: :dormant) if early
|
|
273
|
+
|
|
274
|
+
Check.new(:unknown, "#{label}, but no import of phlex/reactive/early was found", name: :dormant,
|
|
275
|
+
fix: "A dormant root is woken by that module — without it the root never mounts. Add to " \
|
|
276
|
+
"your entrypoint, before any controller loads:\n import \"phlex/reactive/early\"")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Every import map the app registers must pin phlex/reactive/early when a
|
|
280
|
+
# dormant root exists (issue #307): a map without the pin cannot resolve
|
|
281
|
+
# the specifier, so the import fails on the pages that map serves. One
|
|
282
|
+
# advisory check per map lacking the pin; none when every map has it.
|
|
283
|
+
# Maps: Rails.application.importmap, or Phlex::Reactive.importmaps (a
|
|
284
|
+
# callable returning { name => Importmap::Map }) for an app with its own.
|
|
285
|
+
def early_pin_check(components)
|
|
286
|
+
return [] if dormant_components(components).empty?
|
|
287
|
+
|
|
288
|
+
import_maps.filter_map do |name, map|
|
|
289
|
+
next if map_pins_early?(map)
|
|
290
|
+
|
|
291
|
+
Check.new(:unknown, "import map \"#{name}\" does not pin phlex/reactive/early", name: :early_pin,
|
|
292
|
+
fix: "A dormant root rendered on a page served by this map cannot load the module. Add to " \
|
|
293
|
+
"the map's file:\n pin \"phlex/reactive/early\", to: \"phlex/reactive/early.min.js\", preload: true")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
215
297
|
# --- rendering --------------------------------------------------------
|
|
216
298
|
|
|
217
299
|
# The full plain-text report: a line per check, plus an indented fix under
|
|
@@ -236,6 +318,27 @@ module Phlex
|
|
|
236
318
|
"phlex/reactive/actions"
|
|
237
319
|
end
|
|
238
320
|
|
|
321
|
+
STIMULUS_ENTRYPOINTS = %w[
|
|
322
|
+
app/javascript/controllers/index.js
|
|
323
|
+
app/javascript/controllers/application.js
|
|
324
|
+
app/javascript/application.js
|
|
325
|
+
].freeze
|
|
326
|
+
EARLY_IMPORT = %r{import\s+["']phlex/reactive/early["']}
|
|
327
|
+
# Closed comments, which may span lines: HTML, JS block and ERB.
|
|
328
|
+
BLOCK_COMMENT = %r{<!--.*?-->|/\*.*?\*/|<%#.*?%>}m
|
|
329
|
+
# What precedes the import on its line when it is commented out: a //
|
|
330
|
+
# (not the one in a URL), an unclosed /*, or a block-comment's leading *.
|
|
331
|
+
COMMENT_BEFORE = %r{(?<!:)//|/\*|\A\s*\*}
|
|
332
|
+
|
|
333
|
+
# Is there an `import "phlex/reactive/early"` in `source` that actually
|
|
334
|
+
# runs? Closed comments are dropped first, then each line is read, so a
|
|
335
|
+
# commented-out import doesn't count.
|
|
336
|
+
def self.imports_early_source?(source)
|
|
337
|
+
source.gsub(BLOCK_COMMENT, "").each_line.any? do
|
|
338
|
+
(match = EARLY_IMPORT.match(it)) && !match.pre_match.match?(COMMENT_BEFORE)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
239
342
|
private
|
|
240
343
|
|
|
241
344
|
# A one-line tally: how many passed, failed, and are advisory/unknown.
|
|
@@ -340,11 +443,7 @@ module Phlex
|
|
|
340
443
|
# small/importmap app often registers inline in <head> rather than in a
|
|
341
444
|
# dedicated entrypoint file. Only existing files are returned.
|
|
342
445
|
def stimulus_registration_files
|
|
343
|
-
candidates =
|
|
344
|
-
app/javascript/controllers/index.js
|
|
345
|
-
app/javascript/controllers/application.js
|
|
346
|
-
app/javascript/application.js
|
|
347
|
-
].map { app_path(it) }
|
|
446
|
+
candidates = STIMULUS_ENTRYPOINTS.map { app_path(it) }
|
|
348
447
|
candidates += ::Dir.glob(app_path("app/views/layouts/**/*.erb"))
|
|
349
448
|
candidates.select { File.exist?(it) }
|
|
350
449
|
end
|
|
@@ -355,6 +454,58 @@ module Phlex
|
|
|
355
454
|
false
|
|
356
455
|
end
|
|
357
456
|
|
|
457
|
+
# The relative path of the first file that imports phlex/reactive/early,
|
|
458
|
+
# or false. The Stimulus registration files go first, then every other app
|
|
459
|
+
# JavaScript entry (vendor excluded) and inline module scripts in views and
|
|
460
|
+
# components (issue #307).
|
|
461
|
+
def imports_early?
|
|
462
|
+
(stimulus_registration_files | early_import_candidates).each do
|
|
463
|
+
return relative(it) if Doctor.imports_early_source?(early_source(it))
|
|
464
|
+
rescue StandardError
|
|
465
|
+
next
|
|
466
|
+
end
|
|
467
|
+
false
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# A Ruby file's `# …` comment lines are not code (a layout that mentions
|
|
471
|
+
# the import in a comment must not pass the check).
|
|
472
|
+
def early_source(path)
|
|
473
|
+
source = File.read(path)
|
|
474
|
+
path.end_with?(".rb") ? source.lines.grep_v(/\A\s*#/).join : source
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def early_import_candidates
|
|
478
|
+
globs = %w[app/javascript/**/*.js app/views/**/*.{rb,erb} app/components/**/*.{rb,erb}].map { app_path(it) }
|
|
479
|
+
::Dir.glob(globs).reject { it.include?("/vendor/") }.sort
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# " (path)" when the import sits outside the JavaScript entrypoints the
|
|
483
|
+
# doctor always scanned (those keep their original output).
|
|
484
|
+
def early_location(early)
|
|
485
|
+
return "" unless early.is_a?(String)
|
|
486
|
+
return "" if STIMULUS_ENTRYPOINTS.include?(early)
|
|
487
|
+
|
|
488
|
+
" (#{early})"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def dormant_components(components)
|
|
492
|
+
components.select { it.respond_to?(:reactive_dormant?) && it.reactive_dormant? }
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def import_maps
|
|
496
|
+
default = ::Rails.application.respond_to?(:importmap) ? ::Rails.application.importmap : nil
|
|
497
|
+
maps = default ? { "application" => default } : {}
|
|
498
|
+
maps.merge(Phlex::Reactive.importmaps&.call || {})
|
|
499
|
+
rescue StandardError
|
|
500
|
+
maps || {}
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def map_pins_early?(map)
|
|
504
|
+
map.packages.key?("phlex/reactive/early")
|
|
505
|
+
rescue StandardError
|
|
506
|
+
true
|
|
507
|
+
end
|
|
508
|
+
|
|
358
509
|
# Only meaningful when importmap is in use. True when importmap is present
|
|
359
510
|
# but the reactive_controller pin is absent (the engine pins it, so this is
|
|
360
511
|
# really "someone overrode importmap.rb and dropped the pin").
|
|
@@ -371,6 +522,12 @@ module Phlex
|
|
|
371
522
|
|
|
372
523
|
# Grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference.
|
|
373
524
|
def csrf_meta_referenced?
|
|
525
|
+
layout_references?("csrf_meta_tags")
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Does any ERB view / Phlex view or component mention `needle` (and, with
|
|
529
|
+
# a block, satisfy it for that file's source)?
|
|
530
|
+
def layout_references?(needle)
|
|
374
531
|
globs = %w[
|
|
375
532
|
app/views/**/*.erb
|
|
376
533
|
app/views/**/*.rb
|
|
@@ -379,7 +536,8 @@ module Phlex
|
|
|
379
536
|
].map { app_path(it) }
|
|
380
537
|
|
|
381
538
|
::Dir.glob(globs).any? do
|
|
382
|
-
File.read(it)
|
|
539
|
+
source = File.read(it)
|
|
540
|
+
source.include?(needle) && (!block_given? || yield(source))
|
|
383
541
|
rescue StandardError
|
|
384
542
|
false
|
|
385
543
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Dormant roots (issue #274). A root declared dormant — `reactive_dormant`
|
|
6
|
+
# on the class, or `reactive_root(dormant: true)` — renders
|
|
7
|
+
# data-reactive-dormant="reactive" INSTEAD of data-controller="reactive":
|
|
8
|
+
# the reactive controller is not mounted (and, when the app loads it lazily,
|
|
9
|
+
# not even fetched) until one of the root's triggers fires. The client's
|
|
10
|
+
# phlex/reactive/early module then moves the identifier into data-controller
|
|
11
|
+
# and the trigger is replayed on connect.
|
|
12
|
+
#
|
|
13
|
+
# A render made for the ACTOR's own reply is AWAKE: the reply only exists
|
|
14
|
+
# because that page's controller is loaded and connected, so a dormant
|
|
15
|
+
# replacement would save nothing and cost one more wake. The endpoint runs
|
|
16
|
+
# the action and its reply (and the defer render) inside Dormant.awake;
|
|
17
|
+
# every other render — the page, a stream built outside a reactive request,
|
|
18
|
+
# and a BROADCAST even when fired inside an action (Dormant.asleep) — stays
|
|
19
|
+
# dormant, because its receivers may never have loaded the client.
|
|
20
|
+
module Dormant
|
|
21
|
+
AWAKE_KEY = :phlex_reactive_dormant_awake
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def awake?
|
|
25
|
+
Thread.current[AWAKE_KEY] ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Render dormant roots awake inside the block (fiber-local, restored on
|
|
29
|
+
# exit — a raise included).
|
|
30
|
+
def awake
|
|
31
|
+
previous = Thread.current[AWAKE_KEY]
|
|
32
|
+
Thread.current[AWAKE_KEY] = true
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[AWAKE_KEY] = previous
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A reactive_lazy(on: :visible) shell (issue #276) has no trigger
|
|
39
|
+
# descriptor — the CONTROLLER's IntersectionObserver materializes it —
|
|
40
|
+
# so a dormant one could never wake. Refused where it is declared, and
|
|
41
|
+
# (for the other declaration order) where the shell renders.
|
|
42
|
+
def reject_visible_lazy!(klass)
|
|
43
|
+
return unless klass.respond_to?(:reactive_lazy_trigger) && klass.reactive_lazy_trigger&.key?(:visible)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"#{klass}: reactive_dormant cannot be combined with reactive_lazy(on: :visible) — a dormant " \
|
|
47
|
+
"root wakes on a trigger event, and a visibility shell has none (the controller's " \
|
|
48
|
+
"IntersectionObserver loads it). Use reactive_lazy(on: \"an:event\"), or drop reactive_dormant."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The inverse, for a render fired INSIDE an awake block that leaves the
|
|
52
|
+
# actor's page — a broadcast. A plain yield when nothing is awake.
|
|
53
|
+
def asleep
|
|
54
|
+
return yield unless Thread.current[AWAKE_KEY]
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
Thread.current[AWAKE_KEY] = nil
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
Thread.current[AWAKE_KEY] = true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -11,20 +11,39 @@ module Phlex
|
|
|
11
11
|
class Engine < ::Rails::Engine
|
|
12
12
|
isolate_namespace Phlex::Reactive
|
|
13
13
|
|
|
14
|
+
# The client's feature modules (issue #275), each its own file at the
|
|
15
|
+
# bare specifier phlex/reactive/features/<name>. The DEFAULT client
|
|
16
|
+
# (phlex/reactive/reactive_controller) is one file with all of them
|
|
17
|
+
# bundled in and never fetches these. The opt-in phlex/reactive/core
|
|
18
|
+
# imports one only when a root on the page uses it. Listed here once; the
|
|
19
|
+
# precompile list and the importmap pins below are built from it. The
|
|
20
|
+
# build script (scripts/build_client.js), the core's own table and the
|
|
21
|
+
# default entry name the same features —
|
|
22
|
+
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9: the default file 26.1 KB; the core 12.4 KB;
|
|
25
|
+
# persist 3.2, defer 2.8, form 1.0, bindings 5.4, compute 2.1, effects
|
|
26
|
+
# 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
|
|
27
|
+
# is the source of truth and holds a ratchet on each.)
|
|
28
|
+
|
|
14
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
|
15
30
|
# and POST /reactive/defer -> #deferred (the pull-lane defer endpoint,
|
|
16
|
-
# issue #165)
|
|
17
|
-
# .
|
|
31
|
+
# issue #165), and GET /reactive/fragment/:id -> FragmentsController#show
|
|
32
|
+
# (the privately cacheable lazy render, issue #277). Apps can change the
|
|
33
|
+
# paths with Phlex::Reactive.action_path / .defer_path / .fragment_path
|
|
34
|
+
# before boot.
|
|
18
35
|
initializer "phlex_reactive.routes" do
|
|
19
36
|
it.routes.append do
|
|
20
37
|
post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
|
|
21
38
|
post Phlex::Reactive.defer_path, to: "phlex/reactive/actions#deferred", as: :phlex_reactive_defer
|
|
39
|
+
get "#{Phlex::Reactive.fragment_path}/:id", to: "phlex/reactive/fragments#show",
|
|
40
|
+
as: :phlex_reactive_fragment
|
|
22
41
|
end
|
|
23
42
|
end
|
|
24
43
|
|
|
25
44
|
# Make the MINIFIED client build available to Propshaft/Sprockets so it can
|
|
26
45
|
# be fingerprinted, served, and pinned in importmap. The browser ships the
|
|
27
|
-
# minified twin (
|
|
46
|
+
# minified twin (about a fifth of the source; `rake build:js`), not
|
|
28
47
|
# the comment-dense source. The .min.js.map is precompiled too so devtools
|
|
29
48
|
# resolves the linked sourcemap back to the readable source on demand.
|
|
30
49
|
initializer "phlex_reactive.assets" do
|
|
@@ -37,6 +56,10 @@ module Phlex
|
|
|
37
56
|
it.config.assets.precompile += %w[
|
|
38
57
|
phlex/reactive/reactive_controller.min.js
|
|
39
58
|
phlex/reactive/reactive_controller.min.js.map
|
|
59
|
+
phlex/reactive/core.min.js
|
|
60
|
+
phlex/reactive/core.min.js.map
|
|
61
|
+
phlex/reactive/early.min.js
|
|
62
|
+
phlex/reactive/early.min.js.map
|
|
40
63
|
phlex/reactive/confirm.min.js
|
|
41
64
|
phlex/reactive/confirm.min.js.map
|
|
42
65
|
phlex/reactive/confirm_predicate.min.js
|
|
@@ -47,6 +70,9 @@ module Phlex
|
|
|
47
70
|
phlex/reactive/inspect.min.js.map
|
|
48
71
|
phlex/reactive/effects.css
|
|
49
72
|
]
|
|
73
|
+
it.config.assets.precompile += CLIENT_FEATURES.flat_map do
|
|
74
|
+
["phlex/reactive/features/#{it}.min.js", "phlex/reactive/features/#{it}.min.js.map"]
|
|
75
|
+
end
|
|
50
76
|
end
|
|
51
77
|
end
|
|
52
78
|
|
|
@@ -55,11 +81,32 @@ module Phlex
|
|
|
55
81
|
# asset pipeline instead (see README).
|
|
56
82
|
initializer "phlex_reactive.importmap", after: "importmap" do
|
|
57
83
|
if defined?(::Importmap::Map) && it.respond_to?(:importmap)
|
|
84
|
+
# The DEFAULT client: one file, the core and every feature bundled.
|
|
58
85
|
it.importmap.pin(
|
|
59
86
|
"phlex/reactive/reactive_controller",
|
|
60
87
|
to: "phlex/reactive/reactive_controller.min.js",
|
|
61
88
|
preload: true
|
|
62
89
|
)
|
|
90
|
+
# The OPT-IN client (issue #275): the controller without its
|
|
91
|
+
# features, which it imports on demand (the feature pins below). An
|
|
92
|
+
# app imports this INSTEAD of reactive_controller, never both. Not
|
|
93
|
+
# preloaded: an app that does not opt in must not fetch it. One that
|
|
94
|
+
# does re-pins it with preload: true, like any entry it imports
|
|
95
|
+
# eagerly.
|
|
96
|
+
it.importmap.pin(
|
|
97
|
+
"phlex/reactive/core",
|
|
98
|
+
to: "phlex/reactive/core.min.js",
|
|
99
|
+
preload: false
|
|
100
|
+
)
|
|
101
|
+
# The early-trigger capture (issue #273): a ~1 KB module the app
|
|
102
|
+
# imports EAGERLY (`import "phlex/reactive/early"`) so a trigger that
|
|
103
|
+
# fires before a lazily loaded controller connects is replayed on
|
|
104
|
+
# connect instead of lost. Preloaded: it must run before the triggers.
|
|
105
|
+
it.importmap.pin(
|
|
106
|
+
"phlex/reactive/early",
|
|
107
|
+
to: "phlex/reactive/early.min.js",
|
|
108
|
+
preload: true
|
|
109
|
+
)
|
|
63
110
|
# The overridable confirm resolver (issue #55). reactive_controller.js
|
|
64
111
|
# imports it by this BARE specifier — `import { confirmResolver } from
|
|
65
112
|
# "phlex/reactive/confirm"` — NOT a relative "./confirm.js" (issue #57:
|
|
@@ -102,6 +149,19 @@ module Phlex
|
|
|
102
149
|
to: "phlex/reactive/inspect.min.js",
|
|
103
150
|
preload: false
|
|
104
151
|
)
|
|
152
|
+
# Feature modules (issue #275), for the opt-in phlex/reactive/core.
|
|
153
|
+
# NOT preloaded: the core imports one only when a root on the page
|
|
154
|
+
# uses it, so a page without such a root never fetches it — and the
|
|
155
|
+
# default client never does. An app on the core that knows it needs
|
|
156
|
+
# one on every page re-pins it with preload: true.
|
|
157
|
+
importmap = it.importmap
|
|
158
|
+
CLIENT_FEATURES.each do
|
|
159
|
+
importmap.pin(
|
|
160
|
+
"phlex/reactive/features/#{it}",
|
|
161
|
+
to: "phlex/reactive/features/#{it}.min.js",
|
|
162
|
+
preload: false
|
|
163
|
+
)
|
|
164
|
+
end
|
|
105
165
|
end
|
|
106
166
|
end
|
|
107
167
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Phlex
|
|
8
|
+
module Reactive
|
|
9
|
+
# Privately cacheable lazy renders (issue #277): the signed id and URL of a
|
|
10
|
+
# `reactive_lazy cache: { max_age: }` component's GET fragment.
|
|
11
|
+
#
|
|
12
|
+
# The id is a DETERMINISTIC signature over the component's identity payload
|
|
13
|
+
# — no expiry, no actor binding, purpose-scoped — so the same component
|
|
14
|
+
# renders the same URL on every page view and the browser's private HTTP
|
|
15
|
+
# cache can reuse the response. It names NO viewer: who the render is for
|
|
16
|
+
# always comes from the session at the endpoint. (Like the identity token it
|
|
17
|
+
# does carry the signed state / record gid — signed, not encrypted.) The
|
|
18
|
+
# purpose makes it disjoint
|
|
19
|
+
# from the identity and defer tokens: a fragment id can't run an action or
|
|
20
|
+
# reach the defer endpoint, and neither of those tokens resolves here.
|
|
21
|
+
#
|
|
22
|
+
# Two unsigned query parameters only shape the browser's cache KEY — the
|
|
23
|
+
# render never reads them:
|
|
24
|
+
# v — a digest of the component's reactive_cache_version (content changed)
|
|
25
|
+
# u — a KEYED digest of its reactive_cache_viewer (who the render is
|
|
26
|
+
# for). The endpoint recomputes it in the CURRENT session and makes
|
|
27
|
+
# the reply cacheable only when it matches, so a URL can only ever
|
|
28
|
+
# hold the render of the viewer it names. A blank viewer names nobody:
|
|
29
|
+
# no `u`, and the reply falls back to `Vary: Cookie`.
|
|
30
|
+
module Fragment
|
|
31
|
+
# The purpose of the previous id format (still verified, see verify).
|
|
32
|
+
PURPOSE = "phlex-reactive/fragment"
|
|
33
|
+
# The purpose the current id's MAC is keyed under (issue #306).
|
|
34
|
+
ID_PURPOSE = "phlex-reactive/fragment-id"
|
|
35
|
+
# 128 bits of MAC: 22 base64url characters.
|
|
36
|
+
MAC_BYTES = 16
|
|
37
|
+
MAC_LENGTH = 22
|
|
38
|
+
BASE64URL = /\A[A-Za-z0-9_-]+\z/
|
|
39
|
+
VIEWER_PURPOSE = "phlex-reactive/fragment-viewer"
|
|
40
|
+
# A viewer is an identity — an id, or a few of them. These bound how far
|
|
41
|
+
# a collection is walked, so no value can make the shell render slow.
|
|
42
|
+
VIEWER_PART_LIMIT = 32
|
|
43
|
+
VIEWER_NODE_LIMIT = 64
|
|
44
|
+
# Components already warned about (by class name): once per component.
|
|
45
|
+
VIEWER_WARNED = Concurrent::Map.new
|
|
46
|
+
|
|
47
|
+
# A reactive_cache_viewer value that cannot name a viewer. Internal: it
|
|
48
|
+
# carries the reason from the walk to viewer_param, which logs it.
|
|
49
|
+
class UnusableViewer < StandardError; end
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# The signed id of an identity payload (issue #306):
|
|
54
|
+
#
|
|
55
|
+
# base64url(JSON payload) + base64url(128-bit MAC)
|
|
56
|
+
#
|
|
57
|
+
# The payload is encoded ONCE and the MAC is a fixed 22 characters, so the
|
|
58
|
+
# id is one URL path segment of base64url characters with no separator
|
|
59
|
+
# (a "." would read as a format suffix to the router). The MAC is keyed
|
|
60
|
+
# through the configured verifier — a digest of its signature over the
|
|
61
|
+
# encoded payload under its own purpose, the same construction as the
|
|
62
|
+
# viewer digest — so it works with any verifier the app configured and
|
|
63
|
+
# cannot be computed without the app's secret. Deterministic: the same
|
|
64
|
+
# identity always renders the same URL, which is what lets the browser's
|
|
65
|
+
# cache hit.
|
|
66
|
+
def sign(payload)
|
|
67
|
+
data = Base64.urlsafe_encode64(JSON.generate(payload.merge("v" => TOKEN_VERSION)), padding: false)
|
|
68
|
+
"#{data}#{compact_mac(data)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The verified, version-upgraded payload, or nil — tampered, a token of
|
|
72
|
+
# another purpose, or no fragment id at all. The current format is tried
|
|
73
|
+
# first, then the previous one: a browser may still hold a page or a
|
|
74
|
+
# cached fragment URL minted before #306 (for up to
|
|
75
|
+
# fragment_cache_max_age_limit).
|
|
76
|
+
def verify(id)
|
|
77
|
+
id = id.to_s
|
|
78
|
+
payload = verify_compact(id) || verify_legacy(id)
|
|
79
|
+
payload && Phlex::Reactive.upgrade_token(payload)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The MAC is checked BEFORE the data is decoded or parsed. Anything but
|
|
83
|
+
# base64url characters is not an id (and never reaches the verifier).
|
|
84
|
+
def verify_compact(id)
|
|
85
|
+
return nil unless id.length > MAC_LENGTH && id.match?(BASE64URL)
|
|
86
|
+
|
|
87
|
+
data = id[0...-MAC_LENGTH]
|
|
88
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(compact_mac(data), id[-MAC_LENGTH..])
|
|
89
|
+
|
|
90
|
+
payload = JSON.parse(Base64.urlsafe_decode64(data))
|
|
91
|
+
payload.is_a?(::Hash) ? payload : nil
|
|
92
|
+
rescue ::ArgumentError, JSON::ParserError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The previous format: the verifier's token (Base64 + "--" + digest)
|
|
97
|
+
# wrapped in urlsafe Base64. Kept for one release (issue #306).
|
|
98
|
+
def verify_legacy(id)
|
|
99
|
+
Phlex::Reactive.verifier.verified(Base64.urlsafe_decode64(id), purpose: PURPOSE)
|
|
100
|
+
rescue ::ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `data` is pinned to US-ASCII (it is base64url): the id is minted as a
|
|
105
|
+
# US-ASCII String but arrives as a UTF-8 param, and a Marshal-serializing
|
|
106
|
+
# verifier (load_defaults < 7.1) signs the encoding along with the bytes.
|
|
107
|
+
def compact_mac(data)
|
|
108
|
+
data = data.dup.force_encoding(Encoding::US_ASCII)
|
|
109
|
+
digest_param(Digest::SHA256.digest(Phlex::Reactive.verifier.generate(data, purpose: ID_PURPOSE)), MAC_BYTES)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The fragment URL the shell renders into data-reactive-defer-src:
|
|
113
|
+
# <fragment_path>/<signed id>[?v=<version digest>][&u=<viewer digest>].
|
|
114
|
+
# `version` is the component's reactive_cache_version (nil → no `v`);
|
|
115
|
+
# `viewer` is its ALREADY-DIGESTED viewer_param (nil → no `u`).
|
|
116
|
+
def src(payload, version: nil, viewer: nil)
|
|
117
|
+
query = []
|
|
118
|
+
query << "v=#{version_param(version)}" unless version.nil?
|
|
119
|
+
query << "u=#{viewer}" if viewer
|
|
120
|
+
path = "#{Phlex::Reactive.fragment_path}/#{sign(payload)}"
|
|
121
|
+
query.empty? ? path : "#{path}?#{query.join("&")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `v` only varies the browser's cache KEY — the endpoint never reads it —
|
|
125
|
+
# so it is digested: short, opaque, and safe for any value an app returns
|
|
126
|
+
# (a Time keeps its sub-second precision, a record its cache key). 64
|
|
127
|
+
# bits as 11 base64url characters: a cache-buster, not a secret.
|
|
128
|
+
def version_param(version)
|
|
129
|
+
key = version.respond_to?(:utc) ? version.utc.strftime("%Y%m%d%H%M%S%N") : version
|
|
130
|
+
digest_param(Digest::SHA256.digest(ActiveSupport::Cache.expand_cache_key(key)), 8)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def digest_param(digest, bytes)
|
|
134
|
+
Base64.urlsafe_encode64(digest.byteslice(0, bytes), padding: false)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The `u` of a reactive_cache_viewer value, or nil when the value names
|
|
138
|
+
# NO viewer — nil, false, a blank String, or an Array with any blank part
|
|
139
|
+
# or Hash with any blank leaf (`[Current.user&.id, locale]` signed out),
|
|
140
|
+
# or a value whose cache key is empty. A blank viewer must never
|
|
141
|
+
# become a shared "anonymous" key: every signed-out or mis-resolved
|
|
142
|
+
# session would then store and read ONE url with no Vary. nil sends the
|
|
143
|
+
# component back to the default mode (`Vary: Cookie`) for that render.
|
|
144
|
+
#
|
|
145
|
+
# KEYED: the digest is taken over the verifier's signature of the value,
|
|
146
|
+
# so it can be neither reversed to the value (a user id is a small space)
|
|
147
|
+
# nor computed for someone else without the app's secret. 128 bits, as
|
|
148
|
+
# 22 base64url characters.
|
|
149
|
+
#
|
|
150
|
+
# NEVER RAISES and never takes long, whatever the hook returned: this runs
|
|
151
|
+
# inside the host page's render. A value that cannot be turned into a key
|
|
152
|
+
# (it raises, it is endless, it is huge) names nobody — the fail-closed
|
|
153
|
+
# default mode — and is logged once per component (`owner`, its class).
|
|
154
|
+
def viewer_param(viewer, owner: nil)
|
|
155
|
+
return nil unless viewer_named?(viewer)
|
|
156
|
+
|
|
157
|
+
signed = Phlex::Reactive.verifier.generate(ActiveSupport::Cache.expand_cache_key(viewer),
|
|
158
|
+
purpose: VIEWER_PURPOSE)
|
|
159
|
+
digest_param(Digest::SHA256.digest(signed), 16)
|
|
160
|
+
rescue UnusableViewer => e
|
|
161
|
+
warn_unusable_viewer(owner, e.message)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn_unusable_viewer(owner, "#{e.class}: #{e.message}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def viewer_named?(viewer)
|
|
167
|
+
named_within?(viewer, [VIEWER_NODE_LIMIT])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Every LEAF must name something. A collection — anything
|
|
171
|
+
# expand_cache_key walks with to_a: an Array, a Hash (keys and values), a
|
|
172
|
+
# Set, a Struct, a sized Enumerator, nested — with one blank part is
|
|
173
|
+
# unnamed, and so is a value whose cache key expands to nothing (an
|
|
174
|
+
# object with a blank to_param). 0 is a viewer.
|
|
175
|
+
#
|
|
176
|
+
# Bounded: a Range is never walked, a collection of more than
|
|
177
|
+
# VIEWER_PART_LIMIT parts or a walk of more than VIEWER_NODE_LIMIT values
|
|
178
|
+
# (a self-referential Array, a to_a that returns itself) is unnamed.
|
|
179
|
+
# `budget` is a one-element counter shared by the whole walk.
|
|
180
|
+
def named_within?(value, budget)
|
|
181
|
+
return false if value.nil? || value == false
|
|
182
|
+
raise UnusableViewer, "a Range is not an identity" if value.is_a?(::Range)
|
|
183
|
+
raise UnusableViewer, "too deep or self-referential" if (budget[0] -= 1).negative?
|
|
184
|
+
return viewer_leaf?(value) unless value.respond_to?(:to_a) && !value.respond_to?(:cache_key)
|
|
185
|
+
|
|
186
|
+
size = value.respond_to?(:size) ? value.size : nil
|
|
187
|
+
raise UnusableViewer, "a collection of unknown or excessive size" unless viewer_size_ok?(size, value)
|
|
188
|
+
|
|
189
|
+
parts = value.to_a
|
|
190
|
+
raise UnusableViewer, "more than #{VIEWER_PART_LIMIT} parts" if parts.size > VIEWER_PART_LIMIT
|
|
191
|
+
|
|
192
|
+
parts.any? && parts.all? { named_within?(it, budget) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def viewer_leaf?(value)
|
|
196
|
+
value.present? && ActiveSupport::Cache.expand_cache_key(value).present?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# An Enumerator must report a finite size; anything sized must be small.
|
|
200
|
+
def viewer_size_ok?(size, value)
|
|
201
|
+
return size <= VIEWER_PART_LIMIT if size.is_a?(::Integer)
|
|
202
|
+
|
|
203
|
+
!value.is_a?(::Enumerator) && size.nil?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Logs why — once per COMPONENT, naming it, since the cause is that
|
|
207
|
+
# component's hook — and answers nil: "this value names nobody".
|
|
208
|
+
def warn_unusable_viewer(owner, reason)
|
|
209
|
+
name = owner.respond_to?(:name) ? owner.name.to_s : owner.to_s
|
|
210
|
+
return nil if VIEWER_WARNED.put_if_absent(name, true)
|
|
211
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
212
|
+
|
|
213
|
+
::Rails.logger&.warn(
|
|
214
|
+
"[phlex-reactive] #{name.presence || "a component"}#reactive_cache_viewer returned a value that " \
|
|
215
|
+
"cannot name a viewer (#{reason}) — treated as no viewer (Vary: Cookie). Return an id, or a small " \
|
|
216
|
+
"Array of ids."
|
|
217
|
+
)
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Test seam: let the once-per-component warnings fire again.
|
|
222
|
+
def reset_viewer_warning!
|
|
223
|
+
VIEWER_WARNED.clear
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The max-age (seconds) the endpoint answers with: the component's
|
|
227
|
+
# declared value, capped by Phlex::Reactive.fragment_cache_max_age_limit.
|
|
228
|
+
def max_age_for(component_class)
|
|
229
|
+
[component_class.reactive_lazy_cache.fetch(:max_age), Phlex::Reactive.fragment_cache_max_age_limit.to_i].min
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|