hibiki_rails 0.1.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 +7 -0
- data/README.md +252 -0
- data/app/assets/javascripts/hibiki.js +231 -0
- data/config/importmap.rb +7 -0
- data/lib/generators/hibiki/rails/generator_helpers.rb +62 -0
- data/lib/generators/hibiki/rails/install/install_generator.rb +112 -0
- data/lib/generators/hibiki/rails/install/templates/channel.rb.tt +8 -0
- data/lib/generators/hibiki/rails/install/templates/connection.rb.tt +8 -0
- data/lib/generators/hibiki/rails/install/templates/hibiki_controller.js.tt +7 -0
- data/lib/generators/hibiki/rails/island/island_generator.rb +63 -0
- data/lib/generators/hibiki/rails/island/templates/channel.rb.tt +23 -0
- data/lib/generators/hibiki/rails/island/templates/display.html.erb.tt +5 -0
- data/lib/generators/hibiki/rails/island/templates/island.html.erb.tt +14 -0
- data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +73 -0
- data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +19 -0
- data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +29 -0
- data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +17 -0
- data/lib/generators/hibiki/rails/stimulus/stimulus_generator.rb +78 -0
- data/lib/generators/hibiki/rails/stimulus/templates/channel.rb.tt +23 -0
- data/lib/generators/hibiki/rails/stimulus/templates/controller.js.tt +16 -0
- data/lib/generators/hibiki/rails/stimulus/templates/display.html.erb.tt +5 -0
- data/lib/generators/hibiki/rails/stimulus/templates/island.html.erb.tt +11 -0
- data/lib/hibiki/rails/broadcasts.rb +52 -0
- data/lib/hibiki/rails/channel.rb +113 -0
- data/lib/hibiki/rails/debounce.rb +41 -0
- data/lib/hibiki/rails/engine.rb +34 -0
- data/lib/hibiki/rails/graph_actor.rb +60 -0
- data/lib/hibiki/rails/helpers.rb +50 -0
- data/lib/hibiki/rails/registry.rb +50 -0
- data/lib/hibiki/rails/version.rb +7 -0
- data/lib/hibiki/rails.rb +28 -0
- data/lib/hibiki_rails.rb +4 -0
- metadata +133 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registers the packaged hibiki client (the "hibiki-rails" module: the
|
|
2
|
+
// engine's importmap pin, or the npm package in bundler apps) as the
|
|
3
|
+
// "hibiki" Stimulus controller — the Ruby helpers hardcode that
|
|
4
|
+
// identifier in data-controller. File-backed on purpose: `bin/rails
|
|
5
|
+
// stimulus:manifest:update` re-derives this exact registration from the
|
|
6
|
+
// filename, so it survives manifest rewrites.
|
|
7
|
+
export { default } from "hibiki-rails"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require_relative "../generator_helpers"
|
|
5
|
+
|
|
6
|
+
module Hibiki
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
# The island shape — the packaged generic "hibiki" controller drives
|
|
10
|
+
# the island, so there is NO per-component JS: a channel and a
|
|
11
|
+
# self-contained view partial stamped through Hibiki::Rails::Helpers
|
|
12
|
+
# (never raw data-hibiki-* attributes — that contract is private to
|
|
13
|
+
# the gem). Emits the same working mini-example as the stimulus
|
|
14
|
+
# shape, over the same Turbo-broadcast transport.
|
|
15
|
+
#
|
|
16
|
+
# Needs the one-time wiring from hibiki:rails:install (the register
|
|
17
|
+
# line + the Helpers include); post_install hints when it's missing.
|
|
18
|
+
class IslandGenerator < ::Rails::Generators::NamedBase
|
|
19
|
+
include GeneratorHelpers
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
|
|
23
|
+
desc "Scaffolds a reactive island on the packaged \"hibiki\" controller: " \
|
|
24
|
+
"channel + a self-contained view partial, no per-component JS."
|
|
25
|
+
|
|
26
|
+
argument :view_path, type: :string, required: false, default: nil,
|
|
27
|
+
desc: "Views directory under app/views (default: NAME)"
|
|
28
|
+
|
|
29
|
+
def create_channel
|
|
30
|
+
template "channel.rb.tt", "app/channels/#{file_path}_channel.rb"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_views
|
|
34
|
+
template "island.html.erb.tt", "app/views/#{view_dir}/_#{file_name}.html.erb"
|
|
35
|
+
template "display.html.erb.tt", "app/views/#{view_dir}/_#{file_name}_display.html.erb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def post_install
|
|
39
|
+
say <<~MSG
|
|
40
|
+
|
|
41
|
+
Render it from any page:
|
|
42
|
+
|
|
43
|
+
<%= render "#{view_dir}/#{file_name}" %>
|
|
44
|
+
|
|
45
|
+
MSG
|
|
46
|
+
wiring_hint
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def view_dir = view_path.presence || file_path
|
|
52
|
+
|
|
53
|
+
def wiring_hint
|
|
54
|
+
return if hibiki_registered? && helpers_included?
|
|
55
|
+
|
|
56
|
+
say_status :hint, "the packaged client is not fully wired " \
|
|
57
|
+
"(register line and/or Helpers include) — run: " \
|
|
58
|
+
"bin/rails g hibiki:rails:install", :yellow
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by hibiki:rails:island — a working mini-example: one state,
|
|
4
|
+
# one derived, one action, one broadcasting effect. The concern owns the
|
|
5
|
+
# actor, the root, the per-action batch, and the [channel_name, cid]
|
|
6
|
+
# stream convention; this class is just the graph and its actions.
|
|
7
|
+
class <%= class_name %>Channel < ApplicationCable::Channel
|
|
8
|
+
include Hibiki::Rails::Channel
|
|
9
|
+
|
|
10
|
+
def build_graph
|
|
11
|
+
@count = Hibiki::State.new(0)
|
|
12
|
+
doubled = Hibiki::Derived.new { @count.value * 2 }
|
|
13
|
+
|
|
14
|
+
Hibiki::Effect.new do
|
|
15
|
+
broadcast_replace target: "<%= display_dom_id %>",
|
|
16
|
+
partial: "<%= view_dir %>/<%= file_name %>_display",
|
|
17
|
+
locals: { count: @count.value, doubled: doubled.value }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Public methods are client-invocable actions; keep helpers private.
|
|
22
|
+
def increment = @count.value += 1
|
|
23
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%%# Stamped through Hibiki::Rails::Helpers (hibiki_island / on) — never
|
|
2
|
+
write the underlying data attributes by hand, they are private to the
|
|
3
|
+
gem and version with its packaged JS. %>
|
|
4
|
+
<%% cid = local_assigns.fetch(:cid) { SecureRandom.uuid } %>
|
|
5
|
+
<%%= tag.div(**hibiki_island(<%= class_name %>Channel, cid:)) do %>
|
|
6
|
+
<%%= turbo_stream_from "<%= stream_name %>", cid %>
|
|
7
|
+
|
|
8
|
+
<%%# Paint-avoidance placeholder only: the controller subscribes AFTER the
|
|
9
|
+
Turbo stream above confirms, so the graph's first broadcast replaces
|
|
10
|
+
this. %>
|
|
11
|
+
<%%= render "<%= view_dir %>/<%= file_name %>_display", count: 0, doubled: 0 %>
|
|
12
|
+
|
|
13
|
+
<p><%%= tag.button("+1", **on(:increment)) %></p>
|
|
14
|
+
<%% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require_relative "../generator_helpers"
|
|
5
|
+
|
|
6
|
+
module Hibiki
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
# The Phlex component shape — the component owns the state
|
|
10
|
+
# (Hibiki::Reactive), the channel owns the transport: one render
|
|
11
|
+
# effect re-renders the same instance and transmits the HTML over
|
|
12
|
+
# the channel's own subscription (no Turbo streams involved).
|
|
13
|
+
# Emits a working mini-example: channel + component + an island
|
|
14
|
+
# wrapper component renderable from any page.
|
|
15
|
+
#
|
|
16
|
+
# Generated code needs the hibiki_phlex gem (and phlex-rails to
|
|
17
|
+
# render components from views) — warn, don't fail, when it isn't
|
|
18
|
+
# in the bundle, so the scaffold can come first.
|
|
19
|
+
class PhlexGenerator < ::Rails::Generators::NamedBase
|
|
20
|
+
include GeneratorHelpers
|
|
21
|
+
|
|
22
|
+
source_root File.expand_path("templates", __dir__)
|
|
23
|
+
|
|
24
|
+
desc "Scaffolds a reactive Phlex component: channel (transmit transport) + " \
|
|
25
|
+
"component + island wrapper. Requires the hibiki_phlex gem."
|
|
26
|
+
|
|
27
|
+
def warn_without_hibiki_phlex
|
|
28
|
+
require "hibiki/phlex"
|
|
29
|
+
rescue LoadError
|
|
30
|
+
say_status :warn, "hibiki_phlex is not in this bundle — the generated channel " \
|
|
31
|
+
"calls Hibiki::Phlex.render_effect. Add `gem \"hibiki_phlex\"` " \
|
|
32
|
+
"(and `gem \"phlex-rails\"` to render components from views).", :yellow
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_channel
|
|
36
|
+
template "channel.rb.tt", "app/channels/#{file_path}_channel.rb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_component
|
|
40
|
+
template "component.rb.tt", "app/components/#{file_path}.rb"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create_island_component
|
|
44
|
+
template "island_component.rb.tt", "app/components/#{file_path}_island.rb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def post_install
|
|
48
|
+
say <<~MSG
|
|
49
|
+
|
|
50
|
+
Render it from any page:
|
|
51
|
+
|
|
52
|
+
<%= render #{class_name}Island.new %>
|
|
53
|
+
|
|
54
|
+
MSG
|
|
55
|
+
register_hint
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The whole component is the transmitted fragment; its root id is
|
|
61
|
+
# the swap key.
|
|
62
|
+
def component_dom_id = name_parts.join("_")
|
|
63
|
+
|
|
64
|
+
def register_hint
|
|
65
|
+
return if hibiki_registered?
|
|
66
|
+
|
|
67
|
+
say_status :hint, "the packaged client is not registered — run: " \
|
|
68
|
+
"bin/rails g hibiki:rails:install", :yellow
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by hibiki:rails:phlex — the component owns the state
|
|
4
|
+
# (Hibiki::Reactive); the channel owns the transport. One render effect,
|
|
5
|
+
# re-rendering THE SAME INSTANCE, its HTML transmitted over the channel's
|
|
6
|
+
# own subscription and swapped in by the fragment's root DOM id.
|
|
7
|
+
class <%= class_name %>Channel < ApplicationCable::Channel
|
|
8
|
+
include Hibiki::Rails::Channel
|
|
9
|
+
|
|
10
|
+
def build_graph
|
|
11
|
+
@component = <%= class_name %>.new
|
|
12
|
+
|
|
13
|
+
# First run is the dependency-collecting initial render.
|
|
14
|
+
Hibiki::Phlex.render_effect(@component) { |html| transmit({ html: }) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Public methods are client-invocable actions; delegate to the component.
|
|
18
|
+
def increment = @component.increment
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by hibiki:rails:phlex — a working mini-example. The component
|
|
4
|
+
# is a plain Ruby object: Hibiki::Reactive gives it per-instance signals
|
|
5
|
+
# read as ordinary method calls (no .value anywhere), Rerenderable lets
|
|
6
|
+
# the channel's render effect re-render this same instance, and Helpers
|
|
7
|
+
# stamps the client's wire protocol.
|
|
8
|
+
class <%= class_name %> < Phlex::HTML
|
|
9
|
+
include Hibiki::Reactive
|
|
10
|
+
include Hibiki::Phlex::Rerenderable
|
|
11
|
+
include Hibiki::Rails::Helpers
|
|
12
|
+
|
|
13
|
+
state :count, 0
|
|
14
|
+
derived(:doubled) { count * 2 }
|
|
15
|
+
|
|
16
|
+
def view_template
|
|
17
|
+
# The root id is the swap key for transmitted fragments — page-unique.
|
|
18
|
+
div(id: "<%= component_dom_id %>") do
|
|
19
|
+
p { "count: #{count} · doubled: #{doubled}" }
|
|
20
|
+
# Inside the swapped fragment on purpose: the island's root-scoped
|
|
21
|
+
# delegation keeps the button working across replacements.
|
|
22
|
+
button(**on(:increment)) { "+1" }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def increment
|
|
27
|
+
self.count += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The island wrapper: one channel subscription per render, identified by
|
|
4
|
+
# a per-page-load cid. Render from any page:
|
|
5
|
+
#
|
|
6
|
+
# <%%= render <%= class_name %>Island.new %>
|
|
7
|
+
class <%= class_name %>Island < Phlex::HTML
|
|
8
|
+
include Hibiki::Rails::Helpers
|
|
9
|
+
|
|
10
|
+
def view_template
|
|
11
|
+
div(**hibiki_island(<%= class_name %>Channel, cid: SecureRandom.uuid)) do
|
|
12
|
+
# A throwaway instance as a paint-avoidance placeholder; the
|
|
13
|
+
# channel's long-lived instance takes over from its first transmit.
|
|
14
|
+
render <%= class_name %>.new
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require_relative "../generator_helpers"
|
|
5
|
+
|
|
6
|
+
module Hibiki
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
# The ChannelController shape — stock Stimulus vocabulary
|
|
10
|
+
# (data-controller / data-action) over the Turbo-broadcast transport.
|
|
11
|
+
# Emits a working mini-example (one state, one derived, one action,
|
|
12
|
+
# one broadcasting effect) meant to be reshaped in place: a channel,
|
|
13
|
+
# a ChannelController subclass, and a self-contained view partial.
|
|
14
|
+
# Importmap apps need no wiring — the controller eager-loads from the
|
|
15
|
+
# controllers directory; jsbundling apps get the import/register pair
|
|
16
|
+
# appended to controllers/index.js (stimulus:manifest:update format).
|
|
17
|
+
class StimulusGenerator < ::Rails::Generators::NamedBase
|
|
18
|
+
include GeneratorHelpers
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
|
|
22
|
+
desc "Scaffolds a reactive component on the subclassable ChannelController " \
|
|
23
|
+
"base: channel + Stimulus controller + a self-contained view partial."
|
|
24
|
+
|
|
25
|
+
argument :view_path, type: :string, required: false, default: nil,
|
|
26
|
+
desc: "Views directory under app/views (default: NAME)"
|
|
27
|
+
|
|
28
|
+
def create_channel
|
|
29
|
+
template "channel.rb.tt", "app/channels/#{file_path}_channel.rb"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_controller
|
|
33
|
+
template "controller.js.tt", "app/javascript/controllers/#{file_path}_controller.js"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Without an importmap there is no eager loader, so the manifest-style
|
|
37
|
+
# index.js must name every controller. Mirror the exact lines
|
|
38
|
+
# `stimulus:manifest:update` would emit for this file.
|
|
39
|
+
def register_controller
|
|
40
|
+
return if importmap?
|
|
41
|
+
return say_status :identical, INDEX_JS, :blue if wired?(INDEX_JS, %(register("#{identifier}")))
|
|
42
|
+
return manual_wiring(INDEX_JS, registration) unless exists?(INDEX_JS)
|
|
43
|
+
|
|
44
|
+
append_to_file INDEX_JS, "\n#{registration}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_views
|
|
48
|
+
template "island.html.erb.tt", "app/views/#{view_dir}/_#{file_name}.html.erb"
|
|
49
|
+
template "display.html.erb.tt", "app/views/#{view_dir}/_#{file_name}_display.html.erb"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post_install
|
|
53
|
+
say <<~MSG
|
|
54
|
+
|
|
55
|
+
Render it from any page:
|
|
56
|
+
|
|
57
|
+
<%= render "#{view_dir}/#{file_name}" %>
|
|
58
|
+
|
|
59
|
+
MSG
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def view_dir = view_path.presence || file_path
|
|
65
|
+
|
|
66
|
+
# Stimulus::Manifest's class-name convention: Admin__CounterController.
|
|
67
|
+
def js_class_name = "#{name_parts.map(&:camelize).join('__')}Controller"
|
|
68
|
+
|
|
69
|
+
def registration
|
|
70
|
+
<<~JS
|
|
71
|
+
import #{js_class_name} from "./#{file_path}_controller"
|
|
72
|
+
application.register("#{identifier}", #{js_class_name})
|
|
73
|
+
JS
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by hibiki:rails:stimulus — a working mini-example: one state,
|
|
4
|
+
# one derived, one action, one broadcasting effect. The concern owns the
|
|
5
|
+
# actor, the root, the per-action batch, and the [channel_name, cid]
|
|
6
|
+
# stream convention; this class is just the graph and its actions.
|
|
7
|
+
class <%= class_name %>Channel < ApplicationCable::Channel
|
|
8
|
+
include Hibiki::Rails::Channel
|
|
9
|
+
|
|
10
|
+
def build_graph
|
|
11
|
+
@count = Hibiki::State.new(0)
|
|
12
|
+
doubled = Hibiki::Derived.new { @count.value * 2 }
|
|
13
|
+
|
|
14
|
+
Hibiki::Effect.new do
|
|
15
|
+
broadcast_replace target: "<%= display_dom_id %>",
|
|
16
|
+
partial: "<%= view_dir %>/<%= file_name %>_display",
|
|
17
|
+
locals: { count: @count.value, doubled: doubled.value }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Public methods are client-invocable actions; keep helpers private.
|
|
22
|
+
def increment = @count.value += 1
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ChannelController } from "hibiki-rails"
|
|
2
|
+
|
|
3
|
+
// Drives <%= class_name %>Channel on the packaged ChannelController base.
|
|
4
|
+
// Plain data-action tokens ("<%= identifier %>#increment") are
|
|
5
|
+
// auto-forwarded as channel actions with no payload — declare a method
|
|
6
|
+
// only when it must build a payload from the event:
|
|
7
|
+
//
|
|
8
|
+
// setStep(event) {
|
|
9
|
+
// this.perform("set_step", { step: event.target.value })
|
|
10
|
+
// }
|
|
11
|
+
export default class extends ChannelController {
|
|
12
|
+
<% if nested? -%>
|
|
13
|
+
// The identifier "<%= identifier %>" cannot infer a namespaced channel.
|
|
14
|
+
static channel = "<%= class_name %>Channel"
|
|
15
|
+
<% end -%>
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%% cid = local_assigns.fetch(:cid) { SecureRandom.uuid } %>
|
|
2
|
+
<div data-controller="<%= identifier %>" data-<%= identifier %>-cid-value="<%%= cid %>">
|
|
3
|
+
<%%= turbo_stream_from "<%= stream_name %>", cid %>
|
|
4
|
+
|
|
5
|
+
<%%# Paint-avoidance placeholder only: the controller subscribes AFTER the
|
|
6
|
+
Turbo stream above confirms, so the graph's first broadcast replaces
|
|
7
|
+
this. %>
|
|
8
|
+
<%%= render "<%= view_dir %>/<%= file_name %>_display", count: 0, doubled: 0 %>
|
|
9
|
+
|
|
10
|
+
<p><button data-action="<%= identifier %>#increment">+1</button></p>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# Rendering helpers for effects: thin wrappers over Turbo::StreamsChannel
|
|
6
|
+
# bound to the channel's #stream_name, so a render effect reads as
|
|
7
|
+
#
|
|
8
|
+
# Hibiki::Effect.new do
|
|
9
|
+
# broadcast_replace target: "count", partial: "counter/count",
|
|
10
|
+
# locals: { count: @count.value }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# All private — a public helper would become a client-invocable action
|
|
14
|
+
# (see the NOTE on Hibiki::Rails::Channel).
|
|
15
|
+
module Broadcasts
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Replace the element with DOM id `target` (accepts partial:/locals:,
|
|
19
|
+
# html:, or anything Turbo's renderer takes).
|
|
20
|
+
def broadcast_replace(target:, **rendering)
|
|
21
|
+
Turbo::StreamsChannel.broadcast_replace_to(*stream_name, target:, **rendering)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Replace via Turbo 8 morphing (<turbo-stream action="replace"
|
|
25
|
+
# method="morph">): keeps focus/scroll where a plain replace resets.
|
|
26
|
+
def broadcast_morph(target:, **rendering)
|
|
27
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
28
|
+
*stream_name, target:, attributes: { method: :morph }, **rendering
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Tell the page to refresh itself (Turbo 8 morph-everything style).
|
|
33
|
+
# Pair with Debounce when many writes should mean one refresh.
|
|
34
|
+
def broadcast_refresh
|
|
35
|
+
Turbo::StreamsChannel.broadcast_refresh_to(*stream_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The Morph-everything style as one call: an effect that tracks
|
|
39
|
+
# whatever `deps` reads and answers changes with a debounced page
|
|
40
|
+
# refresh — one refresh per burst of actions, not one per action.
|
|
41
|
+
# The initial (dependency-collecting) run broadcasts immediately, as
|
|
42
|
+
# every Effect.new does. Requires the channel context (graph_actor).
|
|
43
|
+
def broadcast_refresh_effect(wait: 0.25, &deps)
|
|
44
|
+
scheduler = Debounce.new(actor: graph_actor, wait:)
|
|
45
|
+
Hibiki::Effect.new(scheduler:) do
|
|
46
|
+
deps.call
|
|
47
|
+
broadcast_refresh
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# Include into an ActionCable channel to host one connection-scoped
|
|
6
|
+
# signal graph:
|
|
7
|
+
#
|
|
8
|
+
# class CounterChannel < ApplicationCable::Channel
|
|
9
|
+
# include Hibiki::Rails::Channel
|
|
10
|
+
#
|
|
11
|
+
# def build_graph # runs on the graph's own thread,
|
|
12
|
+
# @count = Hibiki::State.new(0) # inside Hibiki.root
|
|
13
|
+
# Hibiki::Effect.new { ... broadcast ... }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def increment = @count.value += 1 # actions are plain methods
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Lifecycle: `subscribed` rejects without a `cid` param, then builds the
|
|
20
|
+
# graph via #build_graph inside Hibiki.root on a dedicated GraphActor
|
|
21
|
+
# thread; every incoming action runs on that same thread inside one
|
|
22
|
+
# Hibiki.batch (N writes per action still mean one re-run per affected
|
|
23
|
+
# effect); `unsubscribed` disposes the root and stops the actor.
|
|
24
|
+
#
|
|
25
|
+
# A channel that overrides `subscribed`/`unsubscribed` itself must call
|
|
26
|
+
# `super`.
|
|
27
|
+
#
|
|
28
|
+
# NOTE: everything this module adds is private (except the
|
|
29
|
+
# #perform_action override, which ActionCable already exposes) — any
|
|
30
|
+
# other public method would be picked up by Channel#action_methods and
|
|
31
|
+
# become client-invocable.
|
|
32
|
+
module Channel
|
|
33
|
+
def self.included(base)
|
|
34
|
+
base.extend(ClassMethods)
|
|
35
|
+
base.include(Broadcasts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module ClassMethods
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# #build_graph is a lifecycle hook, not a client-invocable action:
|
|
42
|
+
# keep it out of action_methods even when an app defines it public
|
|
43
|
+
# (a client performing "build_graph" would rebuild the graph and
|
|
44
|
+
# leak the old root).
|
|
45
|
+
def internal_methods = super + [:build_graph]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ActionCable's single dispatch point for incoming actions. The whole
|
|
49
|
+
# action body is posted to the graph's thread and wrapped in one
|
|
50
|
+
# batch — cable threads never touch the graph. Spike-verified: batch
|
|
51
|
+
# per action is all the coalescing the replace-partial style needs.
|
|
52
|
+
#
|
|
53
|
+
# `rescue_from` handlers still run (dispatch_action applies them
|
|
54
|
+
# inside the job, now on the graph thread); what they don't handle
|
|
55
|
+
# propagates to the actor's on_error — ::Rails.error by default.
|
|
56
|
+
def perform_action(data)
|
|
57
|
+
# A nil actor means the subscription was rejected or already torn
|
|
58
|
+
# down; drop the action rather than blow up the cable thread.
|
|
59
|
+
@__hibiki_actor&.post { Hibiki.batch { super(data) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def subscribed
|
|
65
|
+
super
|
|
66
|
+
return reject if cid.blank?
|
|
67
|
+
|
|
68
|
+
@__hibiki_actor = build_graph_actor
|
|
69
|
+
# The block runs owned + untracked (Hibiki.root); the effects it
|
|
70
|
+
# creates do their dependency-collecting first run right here, on
|
|
71
|
+
# the graph thread.
|
|
72
|
+
@__hibiki_actor.post { @__hibiki_root = Hibiki.root { build_graph } }
|
|
73
|
+
Hibiki::Rails.registry.register(self)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ActionCable calls this even after a rejected subscription — hence
|
|
77
|
+
# the guard. Queue#close inside GraphActor#stop lets the posted
|
|
78
|
+
# dispose drain before the worker exits.
|
|
79
|
+
def unsubscribed
|
|
80
|
+
super
|
|
81
|
+
return unless @__hibiki_actor
|
|
82
|
+
|
|
83
|
+
Hibiki::Rails.registry.unregister(self)
|
|
84
|
+
@__hibiki_actor.post { @__hibiki_root.dispose }
|
|
85
|
+
@__hibiki_actor.stop
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build the signal graph: create states/deriveds/effects as instance
|
|
89
|
+
# variables so actions can reach them. Runs once, on the graph's
|
|
90
|
+
# thread, inside Hibiki.root.
|
|
91
|
+
def build_graph
|
|
92
|
+
raise NotImplementedError, "#{self.class} must implement #build_graph"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Per-page-load graph identity, supplied by the page's subscription
|
|
96
|
+
# (each tab is its own graph). Override to derive identity elsewhere.
|
|
97
|
+
def cid = params[:cid]
|
|
98
|
+
|
|
99
|
+
# Streamables the channel broadcasts to, matching the page's
|
|
100
|
+
# <%= turbo_stream_from channel_name, cid %>
|
|
101
|
+
# Override when the page uses different streamables.
|
|
102
|
+
def stream_name = [channel_name, cid]
|
|
103
|
+
|
|
104
|
+
# Seam for a future pooled executor with per-graph ordering; today,
|
|
105
|
+
# one worker thread per graph.
|
|
106
|
+
def build_graph_actor = GraphActor.new(name: "hibiki-#{channel_name}"[0, 15])
|
|
107
|
+
|
|
108
|
+
# The channel's graph executor, for helpers that schedule work back
|
|
109
|
+
# onto the graph thread (Broadcasts#broadcast_refresh_effect).
|
|
110
|
+
def graph_actor = @__hibiki_actor
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# An Effect scheduler (Vue's ReactiveEffect scheduler; the core seam is
|
|
6
|
+
# pinned by the "scheduler:" group in hibiki's spec/effect_spec.rb) that
|
|
7
|
+
# coalesces invalidation bursts into at most one run per `wait` window —
|
|
8
|
+
# trailing edge, so the run sees then-current state.
|
|
9
|
+
#
|
|
10
|
+
# Hibiki.batch already coalesces the writes WITHIN one action; this is
|
|
11
|
+
# for the cross-action case, where many actions in quick succession
|
|
12
|
+
# should mean one re-run — Turbo 8's broadcast_refresh style wants one
|
|
13
|
+
# page refresh per burst, not one per action.
|
|
14
|
+
#
|
|
15
|
+
# Threading: the scheduler fires on the graph's actor thread (effects
|
|
16
|
+
# only re-run there), and the disarm+run is posted back to the same
|
|
17
|
+
# actor, so @armed needs no lock. The timer thread only sleeps. If the
|
|
18
|
+
# actor stops before the timer fires, post returns false and the run is
|
|
19
|
+
# dropped — teardown wins, same as Effect#run no-opping once disposed.
|
|
20
|
+
class Debounce
|
|
21
|
+
def initialize(actor:, wait:)
|
|
22
|
+
@actor = actor
|
|
23
|
+
@wait = wait
|
|
24
|
+
@armed = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(effect)
|
|
28
|
+
return if @armed
|
|
29
|
+
|
|
30
|
+
@armed = true
|
|
31
|
+
Thread.new do
|
|
32
|
+
sleep @wait
|
|
33
|
+
@actor.post do
|
|
34
|
+
@armed = false
|
|
35
|
+
effect.run
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Railties leans on ActiveSupport's core extensions being loaded; require
|
|
4
|
+
# them explicitly so hibiki_rails also loads outside a booted Rails app
|
|
5
|
+
# (the gem's own unit specs, a console).
|
|
6
|
+
require "active_support"
|
|
7
|
+
require "active_support/core_ext"
|
|
8
|
+
require "rails/engine"
|
|
9
|
+
|
|
10
|
+
module Hibiki
|
|
11
|
+
module Rails
|
|
12
|
+
# An Engine (not a bare Railtie) so the vendored client rides Rails'
|
|
13
|
+
# own :append_assets_path initializer: app/assets/javascripts/hibiki.js
|
|
14
|
+
# lands on the host app's asset paths and Propshaft serves it like an
|
|
15
|
+
# app-local file — turbo-rails-style distribution, no install-time copy.
|
|
16
|
+
class Engine < ::Rails::Engine
|
|
17
|
+
# Dev reloading: before code reloads, dispose every live graph — its
|
|
18
|
+
# effects hold blocks from the stale class versions. Also fires once
|
|
19
|
+
# at boot, when the registry is empty (harmless no-op).
|
|
20
|
+
initializer "hibiki_rails.reloader" do |app|
|
|
21
|
+
app.config.to_prepare { Hibiki::Rails.registry.dispose_all }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# importmap-rails apps get the "hibiki-rails" pin with zero config (the
|
|
25
|
+
# guard keeps jsbundling/vite apps booting; they consume the npm
|
|
26
|
+
# package — or a manual copy — instead).
|
|
27
|
+
initializer "hibiki_rails.importmap", before: "importmap" do |app|
|
|
28
|
+
next unless app.config.respond_to?(:importmap)
|
|
29
|
+
|
|
30
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|