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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +252 -0
  3. data/app/assets/javascripts/hibiki.js +231 -0
  4. data/config/importmap.rb +7 -0
  5. data/lib/generators/hibiki/rails/generator_helpers.rb +62 -0
  6. data/lib/generators/hibiki/rails/install/install_generator.rb +112 -0
  7. data/lib/generators/hibiki/rails/install/templates/channel.rb.tt +8 -0
  8. data/lib/generators/hibiki/rails/install/templates/connection.rb.tt +8 -0
  9. data/lib/generators/hibiki/rails/install/templates/hibiki_controller.js.tt +7 -0
  10. data/lib/generators/hibiki/rails/island/island_generator.rb +63 -0
  11. data/lib/generators/hibiki/rails/island/templates/channel.rb.tt +23 -0
  12. data/lib/generators/hibiki/rails/island/templates/display.html.erb.tt +5 -0
  13. data/lib/generators/hibiki/rails/island/templates/island.html.erb.tt +14 -0
  14. data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +73 -0
  15. data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +19 -0
  16. data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +29 -0
  17. data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +17 -0
  18. data/lib/generators/hibiki/rails/stimulus/stimulus_generator.rb +78 -0
  19. data/lib/generators/hibiki/rails/stimulus/templates/channel.rb.tt +23 -0
  20. data/lib/generators/hibiki/rails/stimulus/templates/controller.js.tt +16 -0
  21. data/lib/generators/hibiki/rails/stimulus/templates/display.html.erb.tt +5 -0
  22. data/lib/generators/hibiki/rails/stimulus/templates/island.html.erb.tt +11 -0
  23. data/lib/hibiki/rails/broadcasts.rb +52 -0
  24. data/lib/hibiki/rails/channel.rb +113 -0
  25. data/lib/hibiki/rails/debounce.rb +41 -0
  26. data/lib/hibiki/rails/engine.rb +34 -0
  27. data/lib/hibiki/rails/graph_actor.rb +60 -0
  28. data/lib/hibiki/rails/helpers.rb +50 -0
  29. data/lib/hibiki/rails/registry.rb +50 -0
  30. data/lib/hibiki/rails/version.rb +7 -0
  31. data/lib/hibiki/rails.rb +28 -0
  32. data/lib/hibiki_rails.rb +4 -0
  33. metadata +133 -0
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stock ActionCable boilerplate. Identify the connection here when your
4
+ # channels need it, e.g. `identified_by :current_user`.
5
+ module ApplicationCable
6
+ class Connection < ActionCable::Connection::Base
7
+ end
8
+ end
@@ -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,5 @@
1
+ <%%# The root id is the broadcast replacement key — keep it in sync with the
2
+ channel effect's target. %>
3
+ <p id="<%= display_dom_id %>">
4
+ count: <strong><%%= count %></strong> &middot; doubled: <strong><%%= doubled %></strong>
5
+ </p>
@@ -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,5 @@
1
+ <%%# The root id is the broadcast replacement key — keep it in sync with the
2
+ channel effect's target. %>
3
+ <p id="<%= display_dom_id %>">
4
+ count: <strong><%%= count %></strong> &middot; doubled: <strong><%%= doubled %></strong>
5
+ </p>
@@ -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