hibiki_rails
Rails glue for hibiki: connection-scoped signal graphs over ActionCable, pushing re-rendered HTML to the page — either through Turbo Streams, or over the channel's own subscription to the gem's packaged client (see "The packaged client").
cable action arrives → mutate signals → effects render partials →
Turbo Streams broadcast → Turbo morphs the DOM
A graph lives per cable connection (in practice: per browser tab), built when the channel subscribes and disposed when it unsubscribes. Effects subscribe to whatever signals they read; when an action writes a signal, exactly the affected effects re-render and broadcast.
Incubating inside the hibiki repo while the core gem is pre-release; will be extracted to its own repository once hibiki 0.1.0 ships and this API stabilizes. Rails >= 8.0 (what's tested), Ruby >= 3.4.
Usage
class CounterChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
# Runs once on the graph's own thread, inside Hibiki.root.
def build_graph
@count = Hibiki::State.new(0)
@step = Hibiki::State.new(1)
doubled = Hibiki::Derived.new { @count.value * 2 }
Hibiki::Effect.new do
broadcast_replace target: "count", partial: "counter/count",
locals: { count: @count.value, doubled: doubled.value }
end
Hibiki::Effect.new do
broadcast_replace target: "step", partial: "counter/step",
locals: { step: @step.value }
end
end
# Actions are plain methods that touch signals directly: each one runs
# on the graph thread inside one Hibiki.batch, so N writes still mean
# one re-run per affected effect.
def increment = @count.value += @step.value
def burst = 10.times { @count.value += 1 }
end
The page supplies a per-page-load graph id (cid) and listens on the
matching stream:
<div data-controller="counter" data-counter-cid-value="<%= @cid %>">
<%= turbo_stream_from "counter", @cid %>
<%= render "count", count: 0, doubled: 0 %> <%# placeholder, see below %>
...
</div>
with @cid = SecureRandom.uuid in the controller action. The channel
broadcasts to [channel_name, cid] — override stream_name (and/or
cid) to derive identity differently.
What the concern does
subscribed— rejects without acidparam, then runsbuild_graphinsideHibiki.rooton a dedicated worker thread (aGraphActor). ActionCable dispatches on a thread pool with no per-channel ordering; hibiki's threading model is confinement — so cable threads only enqueue, and the graph lives on exactly one thread.- every action — the whole body is posted to that thread wrapped in one
Hibiki.batch.rescue_fromstill applies (it runs on the graph thread); what it doesn't handle goes toRails.error(source"hibiki_rails"). unsubscribed— disposes the root (runningon_cleanuphooks) and stops the worker, draining what was already queued.- dev reloading — an Engine hook disposes every live graph before code reloads (stale effects would run old class versions forever); cable clients auto-reconnect and rebuild. Graph state resets on reload, like any remount.
Broadcast helpers
Available inside effects (all bound to stream_name):
broadcast_replace(target:, **rendering)—partial:/locals:,html:, or anything Turbo's renderer accepts.broadcast_morph(target:, **rendering)— replace via Turbo 8 morphing (keeps focus/scroll).broadcast_refresh— tell the page to refresh itself.broadcast_refresh_effect(wait: 0.25) { ...read signals... }— the morph-everything style: tracks whatever the block reads and answers changes with a debounced refresh, one per burst of actions rather than one per action.
The packaged client
The gem vendors its own JavaScript, turbo-rails-style: the engine puts
hibiki.js on the app's asset path and merges the "hibiki-rails" pin into the
import map, so importmap-rails apps have no install step beyond
registering the controller — bin/rails g hibiki:rails:install does it
(plus the Helpers include below, the ApplicationCable boilerplate,
and the @rails/actioncable pin — a stock app has neither until its
first rails g channel), or create the one-line shim yourself:
// app/javascript/controllers/hibiki_controller.js
export { default } from "hibiki-rails" // registers as "hibiki" — the helpers hardcode that identifier
The registration is a file-backed shim on purpose: importmap apps
eager-load it from the controllers directory, jsbundling apps get the
matching import/register pair in controllers/index.js (the install
generator appends it), and because it is derived from a real controller
file, bin/rails stimulus:manifest:update regenerates it instead of
dropping it.
(jsbundling/vite apps: npm install hibiki-rails — the npm
package is the same module the
engine vendors, and pulls in @rails/actioncable; release in lockstep with
the gem.)
The client is one generic Stimulus controller that drives any island: a
DOM subtree bound to one channel subscription. Islands are stamped with
the opt-in Hibiki::Rails::Helpers — include it where you want the bare
names (ApplicationHelper for ERB, individual Phlex components); the gem
never includes it for you:
<%= tag.div(**hibiki_island(TodosChannel, cid: @cid)) do %>
<%= render TodoList.new %> <%# placeholder; replaced by DOM id %>
<%= tag.form(**on(:add, event: :submit)) do %>
<input type="text" name="title">
<button>add</button>
<% end %>
<% end %>
hibiki_island(channel, cid:)— the island root: one subscription, identified by the page'scid.on(action, event:, with:)— forward a DOM event (:clickdefault,:change,:submit) as a channel action, withwith:as its payload. A changed control also sends{ name => value }; a submitted form sends its FormData and is reset after performing.
Transport is the channel's own subscription in both directions: render
effects call transmit({ html: }) and the client swaps each fragment in
by its root DOM id (Hibiki::Phlex.render_effect pairs naturally):
def build_graph
@list = TodoList.new
Hibiki::Phlex.render_effect(@list) { |html| transmit({ html: }) }
end
Because the client registers its received callback at subscribe time —
before the server ever runs build_graph — the effects' first transmits
always land: no Turbo stream, no connected-wait, and the server-rendered
initial HTML is only a paint-avoidance placeholder. One rule carries over
from any replace-fragment design: never transmit a fragment containing
the input the user is currently typing in.
The data-hibiki-* attributes the helpers emit are a private contract
with the vendored JS — they version together; don't hand-write them in
app code. The protocol itself is Stimulus-free (Stimulus only hosts the
controller lifecycle), so a hand-rolled client can drive the same
attributes: toy-phlex/ in the parent repo does it in ~40 lines. The
helper interface's shape is inspired by
phlex-reactive's on(...)
actions.
Generators
Each supported shape has a generator that scaffolds it as a working
mini-example — one state, one derived, one action, one effect; run it,
render the output from any page, click +1, watch it live-update — meant
to be reshaped in place, not filled in from scratch:
bin/rails g hibiki:rails:install # one-time wiring: register line,
# Helpers include, ApplicationCable
# boilerplate + actioncable pin
# (idempotent)
bin/rails g hibiki:rails:stimulus NAME [VIEW_PATH] # channel + ChannelController
# subclass + view partial
bin/rails g hibiki:rails:island NAME [VIEW_PATH] # channel + helpers-stamped view
# partial, no per-component JS
bin/rails g hibiki:rails:phlex NAME # channel + Phlex component +
# island wrapper (needs hibiki_phlex)
VIEW_PATH is the views directory under app/views (defaults to NAME);
the emitted partial is self-contained (cid defaults to a per-render
uuid), so <%= render "counter/counter" %> — or <%= render CounterIsland.new %> for the Phlex shape — is the only line a page needs.
The stimulus shape works with zero wiring; island and phlex need the
one-time hibiki:rails:install (they print a hint when it's missing).
Namespaced names work (admin/counter pins static channel where the
Stimulus identifier can't infer it). In apps without an importmap
(jsbundling/vite), where controllers/index.js has no eager loader, the
stimulus generator also appends the controller's import/register pair
to it — the same lines stimulus:manifest:update would emit.
The initial-state pattern (Turbo transport)
Islands on the Turbo-broadcast transport instead (the "Usage" example
above) have an ordering problem the transmit transport doesn't: the
graph's effects do their first run inside subscribed — usually before
the page's turbo_stream_from subscription has confirmed — so the first
broadcast would be lost. Fix the ordering on the client with the packaged
streamConnected helper: wait for Turbo to stamp the connected
attribute on the stream source, then subscribe the graph channel.
// in the Stimulus controller driving the channel
import { streamConnected } from "hibiki-rails"
async connect() {
this.consumer = createConsumer()
await streamConnected(this.element.querySelector("turbo-cable-stream-source"))
this.subscription = this.consumer.subscriptions.create(
{ channel: "CounterChannel", cid: this.cidValue }, {}
)
}
With that in place the server-rendered initial HTML is only a paint-avoidance placeholder — the first broadcast always lands and replaces it, so it doesn't have to match the graph's initial state.
Error handling layers
rescue_fromon the channel — handles action errors, on the graph thread.Hibiki.error_handler = ->(error, effect) { ... }— app-level routing for effect errors raised during a flush (the gem does not set this).- The graph worker's per-job rescue — everything unhandled lands in
Rails.error.report(..., source: "hibiki_rails"). Override per channel viabuild_graph_actorandGraphActor.new(on_error:).
Development
bundle exec rake # specs + rubocop (same as CI)
The spec suite boots a minimal inline Rails app (spec/support/dummy_app.rb);
the live end-to-end proof app is spike/ in the parent repo.