Skip to content

Commit 11730e8

Browse files
committed
feat: add personalized state payload broadcasts
Actors can declare broadcast_payload to send one personalized JSON state payload over the actor stream a page already has open, instead of one HTTP component refresh per changed component. The block runs once per subscriber with that subscriber's authorization context, so private actor state never crosses sessions. Payloads carry actor identity and the monotonic state revision, and the channel and browser both drop stale revisions. ERB component refreshes remain the default and are unchanged.
1 parent 4385728 commit 11730e8

21 files changed

Lines changed: 602 additions & 21 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
6+
payload over the actor stream a page already has open. The block runs once per
7+
subscriber with that subscriber's authorization context, so private state
8+
never crosses sessions. Payloads carry actor identity and the monotonic state
9+
revision, and both the channel and the browser drop stale revisions. Subscribe
10+
with `solid_object room, payloads: :playmat_state` and handle the
11+
`solid-objects:payload` DOM event. ERB component refreshes remain the default
12+
and are unchanged.
13+
314
## 0.5.2 - 2026-08-09
415

516
- Read the database clock once per transaction instead of once per step, and
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const deliveredRevisions = new Map()
2+
3+
class SolidObjectsPayloadElement extends HTMLElement {
4+
connectedCallback() {
5+
if (this.dataset.started === "true") return
6+
7+
this.dataset.started = "true"
8+
this.deliver()
9+
}
10+
11+
deliver() {
12+
try {
13+
const name = this.dataset.name
14+
const revision = revisionFor(this)
15+
const scope = this.closest("[id]")
16+
if (!name || !revision || !scope) return
17+
18+
const key = `${scope.id}:${name}`
19+
if (!newerRevision(revision, deliveredRevisions.get(key))) return
20+
21+
const payload = JSON.parse(this.textContent)
22+
deliveredRevisions.set(key, revision)
23+
scope.dispatchEvent(
24+
new CustomEvent("solid-objects:payload", {
25+
bubbles: true,
26+
detail: {
27+
name,
28+
instanceId: revision[0],
29+
revision: revision[1],
30+
payload
31+
}
32+
})
33+
)
34+
} catch {
35+
this.dispatchEvent(
36+
new CustomEvent("solid-objects:payload-error", {
37+
bubbles: true,
38+
detail: { reason: "invalid_payload" }
39+
})
40+
)
41+
} finally {
42+
this.remove()
43+
}
44+
}
45+
}
46+
47+
function newerRevision(candidate, current) {
48+
if (!current) return true
49+
50+
return candidate[0] > current[0] ||
51+
(candidate[0] === current[0] && candidate[1] > current[1])
52+
}
53+
54+
function revisionFor(element) {
55+
const revision = element.dataset.revision
56+
if (!revision) return
57+
58+
const values = revision.split(":").map(Number)
59+
if (
60+
values.length !== 2 ||
61+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
62+
) return
63+
64+
return values
65+
}
66+
67+
if (!customElements.get("solid-objects-payload")) {
68+
customElements.define("solid-objects-payload", SolidObjectsPayloadElement)
69+
}

‎app/helpers/solid_objects/actor_helper.rb‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
module SolidObjects
44
module ActorHelper
5-
# @rbs (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
6-
def solid_object(reference, authorization_context: self, &block)
5+
# @rbs (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
6+
def solid_object(reference, authorization_context: self, payloads: nil, &block)
7+
payload_names = Array(payloads).map(&:to_s).uniq.presence
78
actor = ActorView.new(
89
reference:,
910
view_context: self,
@@ -13,7 +14,8 @@ def solid_object(reference, authorization_context: self, &block)
1314
subscription_data = {
1415
token: StreamToken.generate(
1516
reference,
16-
observables: actor.scalar_observable_names
17+
observables: actor.scalar_observable_names,
18+
payloads: payload_names
1719
)
1820
}
1921
if actor.component_tokens.any?
@@ -30,10 +32,17 @@ def solid_object(reference, authorization_context: self, &block)
3032
data: { turbo_track: "reload" }
3133
)
3234
end
35+
payload_client = if payload_names
36+
javascript_include_tag(
37+
"solid_objects/state_payload",
38+
type: "module",
39+
data: { turbo_track: "reload" }
40+
)
41+
end
3342

3443
content_tag(
3544
:div,
36-
safe_join([ refresh_client, subscription, content ].compact),
45+
safe_join([ refresh_client, payload_client, subscription, content ].compact),
3746
id: DomIdentity.scope(reference)
3847
)
3948
end

‎docs/realtime.md‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,93 @@ applications discover the namespaced engine asset. Applications created with
110110
explicitly serve the module. Turbo's normal morph rules still apply; use
111111
`data-turbo-permanent` for elements that must never be changed.
112112

113+
## Personalized state payloads
114+
115+
Reactive ERB components cost one browser request per changed component. When a
116+
single actor mutation changes several components, an application pays several
117+
round trips for one logical update. A payload broadcast collapses that into one
118+
message on the stream the page already has open.
119+
120+
Declare the payload on the actor. The block receives the actor and the
121+
subscriber's authorization context, and it runs **once per subscriber**, so two
122+
sessions watching the same actor never see each other's private state:
123+
124+
```ruby
125+
class PlaymatRoom < SolidObjects::Actor
126+
actor_type "playmat_room"
127+
128+
attribute :hands, default: -> { {} }
129+
attribute :turn, default: 1
130+
131+
observable :turn
132+
133+
broadcast_payload :playmat_state do |room, authorization_context|
134+
{
135+
"turn" => room.turn,
136+
"hand" => room.hands.fetch(authorization_context.session_id, [])
137+
}
138+
end
139+
end
140+
```
141+
142+
Subscribe the scope to it:
143+
144+
```erb
145+
<%= solid_object room, payloads: :playmat_state do |actor| %>
146+
<div data-playmat></div>
147+
<% end %>
148+
```
149+
150+
Handle it with any JavaScript. The gem dispatches a DOM event and requires no
151+
framework:
152+
153+
```javascript
154+
document.addEventListener("solid-objects:payload", (event) => {
155+
const { name, revision, payload } = event.detail
156+
if (name !== "playmat_state") return
157+
158+
renderPlaymat(payload)
159+
})
160+
```
161+
162+
### What the protocol guarantees
163+
164+
The payload travels as a Turbo Stream element on the existing actor stream, so
165+
applications do not run a second WebSocket system. Each message carries the
166+
actor identity plus the `instance_id` and monotonic `state_revision` that fence
167+
component refreshes, and both the channel and the browser drop a payload that
168+
is not newer than the last one delivered for that scope and name. A reconnecting
169+
client receives the current payload on subscribe.
170+
171+
Authorization is the same `authorize_query` boundary that components use, called
172+
with the payload name and the subscriber's Cable connection. A subscriber that
173+
fails the check is skipped rather than served a partial payload, and the payload
174+
name is signed into the stream token, so a browser cannot ask for a payload the
175+
server did not offer.
176+
177+
Payload blocks read committed actor state through the same snapshot components
178+
use. They cannot write application records, and the return value must be a JSON
179+
object or array so the wire format stays inspectable.
180+
181+
### mtg-playmat before and after
182+
183+
Before, one mutation that touched three observables produced three refresh
184+
elements and three HTTP requests:
185+
186+
```
187+
commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 renders
188+
```
189+
190+
After, the same mutation delivers one personalized payload and the page renders
191+
once:
192+
193+
```
194+
commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render
195+
```
196+
197+
Components remain the default. An actor with no `broadcast_payload` and a scope
198+
with no `payloads:` option behave exactly as before.
199+
113200
## Authorization
114201

115202
The HTML contains a signed actor identity token. Signing prevents modification;

‎lib/solid_objects.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
require "solid_objects/component_subscriptions"
4141
require "solid_objects/component_view"
4242
require "solid_objects/component_renderer"
43+
require "solid_objects/payload_broadcast"
4344
require "solid_objects/state_snapshot"
4445
require "solid_objects/actor_view"
4546
require "solid_objects/actor_channel"

‎lib/solid_objects/actor.rb‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ def observable(name, &block)
5757
definition.add_observable(name, block)
5858
end
5959

60+
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
61+
def broadcast_payload(name, &block)
62+
raise InvalidActor, "payload broadcasts require a block" unless block
63+
64+
definition.add_payload_broadcast(name, block)
65+
end
66+
6067
# @rbs (?Integer) -> Integer
6168
def state_version(version = nil)
6269
definition.set_state_version(version) if version

‎lib/solid_objects/actor_channel.rb‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ def subscribed
1919

2020
@reference = Reference.new(actor_type:, actor_id:)
2121
@scalar_observables = identity["observables"]
22+
@payload_names = identity["payloads"]
2223
validate_scalar_observables!
24+
validate_payload_names!
2325
@component_subscriptions = ComponentSubscriptions.parse(
2426
params["components"],
2527
reference:
@@ -36,6 +38,7 @@ def subscribed
3638
)
3739
end
3840
refresh_outdated_components(snapshot)
41+
transmit_state_payloads(snapshot)
3942
rescue KeyError,
4043
JSON::ParserError,
4144
InvalidStreamToken,
@@ -46,7 +49,10 @@ def subscribed
4649

4750
private
4851

49-
attr_reader :reference, :component_subscriptions, :scalar_observables
52+
attr_reader :reference,
53+
:component_subscriptions,
54+
:scalar_observables,
55+
:payload_names
5056

5157
# @rbs (String) -> void
5258
def receive_broadcast(stream)
@@ -61,6 +67,48 @@ def receive_broadcast(stream)
6167
component_subscriptions
6268
.refreshes_for(invalidation)
6369
.each { |refresh| transmit refresh }
70+
transmit_state_payloads(ActorSnapshot.new(reference))
71+
end
72+
73+
# @rbs (ActorSnapshot) -> void
74+
def transmit_state_payloads(snapshot)
75+
return if payload_names.nil? || payload_names.empty?
76+
return unless newer_payload_revision?(snapshot)
77+
78+
payload_names.each do |name|
79+
payload = PayloadBroadcast.new(
80+
snapshot:,
81+
name:,
82+
authorization_context: connection
83+
).call
84+
transmit TurboStreamRenderer.state_payload(payload)
85+
rescue Unauthorized
86+
next
87+
end
88+
@payload_revision = [ snapshot.instance_id, snapshot.revision ]
89+
end
90+
91+
# @rbs (ActorSnapshot) -> bool
92+
def newer_payload_revision?(snapshot)
93+
current = @payload_revision
94+
return true unless current
95+
96+
(current <=> [ snapshot.instance_id, snapshot.revision ]) == -1
97+
end
98+
99+
# @rbs () -> void
100+
def validate_payload_names!
101+
return unless payload_names
102+
103+
broadcasts = SolidObjects
104+
.registry
105+
.fetch(reference.actor_type)
106+
.definition
107+
.payload_broadcasts
108+
unknown = payload_names.find { |name| !broadcasts.key?(name.to_sym) }
109+
return unless unknown
110+
111+
raise InvalidStreamToken, "unknown payload broadcast #{unknown.inspect}"
64112
end
65113

66114
# @rbs (ActorSnapshot) -> void

‎lib/solid_objects/actor_definition.rb‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ActorDefinition
99
# @rbs @messages: Hash[Symbol, Handler]
1010
# @rbs @queries: Hash[Symbol, Handler]
1111
# @rbs @observables: Hash[Symbol, Handler]
12+
# @rbs @payload_broadcasts: Hash[Symbol, Handler]
1213
# @rbs @state_version: Integer
1314
# @rbs @state_migrations: Array[StateMigration]
1415
# @rbs @activation_hooks: Array[Proc]
@@ -20,6 +21,7 @@ class ActorDefinition
2021
:messages,
2122
:queries,
2223
:observables,
24+
:payload_broadcasts,
2325
:state_version,
2426
:state_migrations,
2527
:activation_hooks,
@@ -31,6 +33,7 @@ def initialize
3133
@messages = {}
3234
@queries = {}
3335
@observables = {}
36+
@payload_broadcasts = {}
3437
@state_version = 1
3538
@state_migrations = []
3639
@activation_hooks = []
@@ -83,6 +86,21 @@ def add_observable(name, block = nil)
8386
end
8487
end
8588

89+
# @rbs (Symbol | String, Proc) -> Handler
90+
def add_payload_broadcast(name, block)
91+
payload_name = name.to_sym
92+
if payload_broadcasts.key?(payload_name)
93+
raise InvalidActor, "#{payload_name.inspect} payload broadcast is already defined"
94+
end
95+
unless payload_name.to_s.match?(/\A[a-zA-Z0-9_]+\z/)
96+
raise InvalidActor, "payload broadcast names may contain only letters, digits, and underscores"
97+
end
98+
99+
Handler.new(name: payload_name, block:).tap do |handler|
100+
payload_broadcasts[payload_name] = handler
101+
end
102+
end
103+
86104
# @rbs (Class) -> ActorDefinition
87105
def synchronize_instance_messages(actor_class)
88106
names = actor_message_method_names(actor_class)
@@ -149,6 +167,7 @@ def duplicate
149167
copy.instance_variable_set(:@messages, messages.dup)
150168
copy.instance_variable_set(:@queries, queries.dup)
151169
copy.instance_variable_set(:@observables, observables.dup)
170+
copy.instance_variable_set(:@payload_broadcasts, payload_broadcasts.dup)
152171
copy.instance_variable_set(:@state_version, state_version)
153172
copy.instance_variable_set(:@state_migrations, state_migrations.dup)
154173
copy.instance_variable_set(:@activation_hooks, activation_hooks.dup)

‎lib/solid_objects/errors.rb‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ class InvalidComponentToken < Error
4343
class UnknownComponent < Error
4444
end
4545

46+
class UnknownPayloadBroadcast < Error
47+
end
48+
49+
class InvalidPayloadBroadcast < Error
50+
end
51+
4652
class UnknownComponentDependency < Error
4753
end
4854

0 commit comments

Comments
 (0)