phlex-reactive 0.14.4 → 0.15.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 +70 -0
- data/README.md +191 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +28 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +175 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +79 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +24 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +105 -0
- data/lib/phlex/reactive/streamable.rb +7 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +11 -1
|
@@ -66,7 +66,9 @@ module Phlex
|
|
|
66
66
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
67
67
|
# WHOLE component) plus a list of named actions.
|
|
68
68
|
skip_all: :@reactive_own_skip_all,
|
|
69
|
-
skip_actions: :@reactive_own_skip_actions
|
|
69
|
+
skip_actions: :@reactive_own_skip_actions,
|
|
70
|
+
# Presence roster — reactive_presence (issue #358).
|
|
71
|
+
presence: :@reactive_own_presence
|
|
70
72
|
}.freeze
|
|
71
73
|
|
|
72
74
|
# The identity-side hot-path memos swept by bump! (see the contract
|
data/lib/phlex/reactive/defer.rb
CHANGED
|
@@ -112,6 +112,11 @@ module Phlex
|
|
|
112
112
|
"got #{placeholder.class}"
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# The signed SSE URL a <pgbus-stream-source> subscribes to `key` with —
|
|
116
|
+
# minted off-view, as pgbus_stream_from would. Shared with presence
|
|
117
|
+
# (issue #358), whose roster subscribes to its stream the same way.
|
|
118
|
+
def stream_src(key) = signed_stream_src(key)
|
|
119
|
+
|
|
115
120
|
private
|
|
116
121
|
|
|
117
122
|
# The delivery directive: `<turbo-stream action="reactive:defer">`
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute sortable effects hints devtools].freeze
|
|
24
|
-
# (Gzipped, bun zlib level 9: the default file
|
|
25
|
-
# persist 3.1, defer
|
|
26
|
-
# 3.1, effects 1.7, hints
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
|
|
25
|
+
# persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
|
|
26
|
+
# 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
|
@@ -202,6 +202,11 @@ module Phlex
|
|
|
202
202
|
Phlex::Reactive.freeze_param_types!
|
|
203
203
|
# Freeze the named-schema registry the same way (issue #184).
|
|
204
204
|
Phlex::Reactive.freeze_param_schemas!
|
|
205
|
+
|
|
206
|
+
# Presence (issue #358): register the presence stream pattern and the
|
|
207
|
+
# change hook with the transport. A no-op without a capable pgbus — an
|
|
208
|
+
# app on Action Cable boots exactly as before.
|
|
209
|
+
Phlex::Reactive::Presence.install!
|
|
205
210
|
end
|
|
206
211
|
end
|
|
207
212
|
end
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -304,6 +304,30 @@ module Phlex
|
|
|
304
304
|
append("dispatch", args.freeze)
|
|
305
305
|
end
|
|
306
306
|
|
|
307
|
+
# --- Presence (issue #358) ---
|
|
308
|
+
#
|
|
309
|
+
# presence(**state) — write this tab's presence state through the root's
|
|
310
|
+
# generated presence_set action (reactive_presence): only the declared
|
|
311
|
+
# keys reach the server, typed like action params. Always targets the
|
|
312
|
+
# ROOT, which must declare reactive_presence (the client warns and does
|
|
313
|
+
# nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
|
|
314
|
+
# every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
|
|
315
|
+
# li(**on_client(:click, js.presence(selected: item.id))) { item.title }
|
|
316
|
+
def presence(**state)
|
|
317
|
+
raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
|
|
318
|
+
|
|
319
|
+
state.each do |name, value|
|
|
320
|
+
next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
|
|
321
|
+
next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
|
|
322
|
+
|
|
323
|
+
raise ArgumentError,
|
|
324
|
+
"#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
|
|
325
|
+
"#{name.inspect} is #{value.class}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
|
|
329
|
+
end
|
|
330
|
+
|
|
307
331
|
# The wire format: a JSON array of [op, args] pairs, applied in order.
|
|
308
332
|
def to_json(*)
|
|
309
333
|
@ops.to_json
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Presence
|
|
6
|
+
# What reactive_presence adds to a component (issue #358). Included only
|
|
7
|
+
# by a class that declares presence, so every other component keeps
|
|
8
|
+
# ActiveSupport's Object#presence.
|
|
9
|
+
module ComponentMethods
|
|
10
|
+
# The roster, read once per render (and again after #presence.set). The
|
|
11
|
+
# memo is namespaced: a component's own @presence must not be clobbered.
|
|
12
|
+
def presence
|
|
13
|
+
@reactive_presence_roster ||= Roster.new( # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
14
|
+
Transport.members(reactive_presence_stream_name),
|
|
15
|
+
keys: self.class.reactive_presence_declaration.state.keys,
|
|
16
|
+
connection_id: Presence.connection_id,
|
|
17
|
+
writer: -> { reactive_presence_write(it) }
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The generated action js.presence sends. Runs authorize_presence_state!
|
|
22
|
+
# first when the component defines it (raise to refuse), writes the
|
|
23
|
+
# actor's tab state, and morphs the actor's own view.
|
|
24
|
+
#
|
|
25
|
+
# The client also sends it EMPTY on every connect (the announce). That
|
|
26
|
+
# writes no value — only the reverse note (this class and the record of
|
|
27
|
+
# the token the actor already holds) on the actor's own row — so there
|
|
28
|
+
# is nothing to authorize, and the hook is skipped: its keywords may be
|
|
29
|
+
# required, and calling it with none would raise.
|
|
30
|
+
def presence_set(**state)
|
|
31
|
+
authorize_presence_state!(**state) if state.any? && respond_to?(:authorize_presence_state!, true)
|
|
32
|
+
presence.set(**state)
|
|
33
|
+
reply.morph
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# data-reactive-presence-peer / -member on a tab's element: the client
|
|
37
|
+
# marks the one that is this tab ("me") and this person's other tabs.
|
|
38
|
+
def reactive_presence_peer(peer)
|
|
39
|
+
{ data: { reactive_presence_peer: peer.seat.to_s, reactive_presence_member: peer.member_id.to_s } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# data-reactive-presence-member / -seats on a person's element.
|
|
43
|
+
def reactive_presence_member(member)
|
|
44
|
+
{ data: { reactive_presence_member: member.id.to_s, reactive_presence_seats: member.seats.size.to_s } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [record, :presence, name] — or [*key, :presence, name] record-less.
|
|
48
|
+
def reactive_presence_stream_parts
|
|
49
|
+
declaration = self.class.reactive_presence_declaration
|
|
50
|
+
[*reactive_presence_subject, :presence, declaration.name]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reactive_presence_stream_name
|
|
54
|
+
@reactive_presence_stream_name ||= reactive_presence_key(reactive_presence_stream_parts)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The root's subscription marker (reactive_attrs): the signed SSE src of
|
|
58
|
+
# the presence stream. The client mints the <pgbus-stream-source> from it.
|
|
59
|
+
# And the seats this render saw: a template need not render an element
|
|
60
|
+
# per tab, so the client diffs these for reactive:presence and checks
|
|
61
|
+
# them to know its own tab is on the roster. Seats only (pgbus limits
|
|
62
|
+
# them to [A-Za-z0-9_-]), never a member or connection id.
|
|
63
|
+
def reactive_presence_data
|
|
64
|
+
Presence.require_capable!(self.class)
|
|
65
|
+
{ reactive_presence: Transport.source_src(reactive_presence_stream_name),
|
|
66
|
+
reactive_presence_roster: presence.peers.filter_map(&:seat).join(" ") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The stream key for the parts. Only pgbus's budget error becomes a
|
|
72
|
+
# ConfigurationError (the backstop for a name the declaration could not
|
|
73
|
+
# check); a missing key keeps its own error.
|
|
74
|
+
def reactive_presence_key(parts)
|
|
75
|
+
Transport.stream_name(parts)
|
|
76
|
+
rescue ::ArgumentError => e # pgbus raises the core one; Phlex defines its own
|
|
77
|
+
raise ConfigurationError, Transport.over_budget_message(self.class, parts.last, e.message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reactive_presence_subject
|
|
81
|
+
declaration = self.class.reactive_presence_declaration
|
|
82
|
+
return declaration.key if declaration.key
|
|
83
|
+
|
|
84
|
+
record_key = self.class.reactive_record_key
|
|
85
|
+
unless record_key
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"#{self.class.name}: reactive_presence needs reactive_record (the stream is the record's) " \
|
|
88
|
+
"or key: (the raw stream key parts of a record-less roster)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
[instance_variable_get(:"@#{record_key}")]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reactive_presence_write(state)
|
|
95
|
+
declared = self.class.reactive_presence_declaration.state.keys
|
|
96
|
+
unknown = state.keys.map(&:to_sym) - declared
|
|
97
|
+
unless unknown.empty?
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"#{self.class.name}: presence.set got undeclared state #{unknown.join(", ")} " \
|
|
100
|
+
"(declared: #{declared.join(", ")})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
connection_id = Presence.connection_id
|
|
104
|
+
return if connection_id.nil?
|
|
105
|
+
|
|
106
|
+
merged = (presence.me&.state || {}).merge(state.transform_keys(&:to_sym)).transform_keys(&:to_s)
|
|
107
|
+
written = Transport.set_state(reactive_presence_stream_name, connection_id:,
|
|
108
|
+
state: merged.merge(NOTE_KEY => reactive_presence_note))
|
|
109
|
+
@reactive_presence_roster = nil
|
|
110
|
+
# No row for this connection (it expired, or pgbus never joined it:
|
|
111
|
+
# no identity): nothing changed for the peers.
|
|
112
|
+
return if written.nil?
|
|
113
|
+
|
|
114
|
+
# A seatless actor (no ?seat=) is on no roster: nothing any viewer
|
|
115
|
+
# sees changed, so there is nothing to broadcast.
|
|
116
|
+
me = presence.me
|
|
117
|
+
return if me.nil?
|
|
118
|
+
|
|
119
|
+
Presence.broadcast(self.class.from_identity(reactive_identity_payload), exclude: connection_id)
|
|
120
|
+
me
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Which component wrote the row: its identity payload (class, record gid,
|
|
124
|
+
# declared reactive_state), which is how the render job rebuilds it from
|
|
125
|
+
# the stream (see Presence::NOTE_KEY) — the same shape a token carries.
|
|
126
|
+
def reactive_presence_note = reactive_identity_payload
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Presence
|
|
6
|
+
# The presence of one stream as one render reads it (issue #358): what
|
|
7
|
+
# `presence` returns in a roster component's view_template and actions.
|
|
8
|
+
# Built from the transport's rows once per render.
|
|
9
|
+
#
|
|
10
|
+
# A row is one SSE connection. Two presence roots in one tab are two
|
|
11
|
+
# connections of one seat, so peers are grouped by seat: one entry per tab.
|
|
12
|
+
class Roster
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
def initialize(rows, keys:, connection_id: nil, writer: nil)
|
|
16
|
+
# seat => its connection ids. Private: a connection id is the bearer
|
|
17
|
+
# key for writing that row, so only `me` reads it.
|
|
18
|
+
@connections = {}
|
|
19
|
+
@peers = build_peers(rows, keys)
|
|
20
|
+
@connection_id = connection_id
|
|
21
|
+
@writer = writer
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every tab, in join order.
|
|
25
|
+
attr_reader :peers
|
|
26
|
+
|
|
27
|
+
def each(&) = @peers.each(&)
|
|
28
|
+
|
|
29
|
+
# The tabs grouped by person, in order of their first tab.
|
|
30
|
+
def members
|
|
31
|
+
@members ||= @peers.group_by(&:member_id).map do |id, seats|
|
|
32
|
+
Member.new(id:, metadata: seats.last.metadata, seats:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The tabs whose state matches, e.g. where(selected: item.id).
|
|
37
|
+
def where(**state) = @peers.select { matches?(it, state) }
|
|
38
|
+
|
|
39
|
+
def count = @peers.size
|
|
40
|
+
|
|
41
|
+
alias size count
|
|
42
|
+
|
|
43
|
+
def empty? = @peers.empty?
|
|
44
|
+
|
|
45
|
+
# The actor's own tab during an action (found by its presence
|
|
46
|
+
# connection), nil in a plain render or before the tab has connected.
|
|
47
|
+
def me
|
|
48
|
+
return if @connection_id.nil?
|
|
49
|
+
|
|
50
|
+
@peers.find { @connections[it.seat].include?(@connection_id) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# In an action: write the actor's tab state (merged into what it had),
|
|
54
|
+
# morph the roster into the other viewers' tabs, return the actor's
|
|
55
|
+
# peer. nil when the actor's tab has no presence connection yet.
|
|
56
|
+
def set(**state) = @writer.call(state)
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def matches?(peer, state) = state.all? { |key, value| peer.state[key] == value }
|
|
61
|
+
|
|
62
|
+
# A row without a seat (a connection that sent no ?seat=: an app's own
|
|
63
|
+
# turbo_stream_from of the stream, a source without `presence`, a stale
|
|
64
|
+
# client) is no peer: no client can mark it "me", and keying it by its
|
|
65
|
+
# connection id would render that id — the bearer key for its row.
|
|
66
|
+
def build_peers(rows, keys)
|
|
67
|
+
rows.select { it["seat"].present? }.group_by { it["seat"] }.map do |seat, seat_rows|
|
|
68
|
+
first = seat_rows.first
|
|
69
|
+
@connections[seat] = seat_rows.map { it["connection_id"] }
|
|
70
|
+
Peer.new(
|
|
71
|
+
seat:, member_id: first["id"].to_s, metadata: seat_rows.last["metadata"] || {},
|
|
72
|
+
state: declared_state(seat_rows, keys), joined_at: first["joined_at"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The seat's state, declared keys only (never the reverse note), merged
|
|
78
|
+
# across its connections in join order.
|
|
79
|
+
def declared_state(seat_rows, keys)
|
|
80
|
+
seat_rows.each_with_object({}) do |row, state|
|
|
81
|
+
(row["state"] || {}).each do |key, value|
|
|
82
|
+
key = key.to_sym
|
|
83
|
+
state[key] = value if keys.include?(key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Presence
|
|
8
|
+
# Every pgbus touchpoint of presence (issue #358), in one place. The
|
|
9
|
+
# transport half lives in pgbus (zoolutions/pgbus#515): presence rows keyed
|
|
10
|
+
# by SSE connection, each with the tab's seat and a state jsonb, a change
|
|
11
|
+
# hook, and a heartbeat that expires silent rows. Nothing else in the gem
|
|
12
|
+
# names a pgbus presence constant or configuration key.
|
|
13
|
+
#
|
|
14
|
+
# pgbus stays optional: the capability is a probe of the real method
|
|
15
|
+
# signatures, never a version string, and without it nothing here runs —
|
|
16
|
+
# reactive_presence raises a ConfigurationError at render instead.
|
|
17
|
+
module Transport
|
|
18
|
+
# Every presence stream's name ends in ":presence:<name>" — the parts
|
|
19
|
+
# [record, :presence, name] joined by pgbus/Turbo. Registered with the
|
|
20
|
+
# transport at boot so a subscription to one joins on connect and leaves
|
|
21
|
+
# on disconnect, with no controller code.
|
|
22
|
+
PATTERN = /:presence:[^:]+\z/
|
|
23
|
+
# Hex characters of the subject's digest that start a presence key.
|
|
24
|
+
DIGEST_LENGTH = 16
|
|
25
|
+
# What joins the digest and the presence name.
|
|
26
|
+
SEPARATOR = ":presence:"
|
|
27
|
+
|
|
28
|
+
# pgbus's default streams_heartbeat_interval (seconds), used when pgbus
|
|
29
|
+
# is absent or does not expose the setting.
|
|
30
|
+
DEFAULT_HEARTBEAT = 15
|
|
31
|
+
|
|
32
|
+
# The change hook install! sets: the app's own hook (if any) first, then
|
|
33
|
+
# presence's. Its class is how a second install! knows it is in place.
|
|
34
|
+
ChainedHook = Data.define(:previous, :on_change) do
|
|
35
|
+
def call(stream_name, change)
|
|
36
|
+
previous&.call(stream_name, change)
|
|
37
|
+
on_change.call(stream_name, change)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# The code is there: pgbus with reactive Streams AND presence keyed by
|
|
43
|
+
# connection (a join that takes connection_id:, a set_state,
|
|
44
|
+
# seats_supported?), a change hook and a ttl setting, and ActiveJob
|
|
45
|
+
# (the roster re-renders off the hook's thread). Signatures only, no
|
|
46
|
+
# query: boot and class load gate on this, and both run before a
|
|
47
|
+
# database exists (`rails db:create`, `assets:precompile`). What it
|
|
48
|
+
# gates is configuration, harmless on an unmigrated table.
|
|
49
|
+
def wired?
|
|
50
|
+
return false unless Phlex::Reactive.pgbus_streams?
|
|
51
|
+
return false unless defined?(::Pgbus::Streams::Presence) && defined?(::ActiveJob::Base)
|
|
52
|
+
|
|
53
|
+
presence = ::Pgbus::Streams::Presence
|
|
54
|
+
join_keys = presence.instance_method(:join).parameters.map(&:last)
|
|
55
|
+
return false unless join_keys.include?(:connection_id) && presence.method_defined?(:set_state)
|
|
56
|
+
return false unless presence.respond_to?(:seats_supported?)
|
|
57
|
+
|
|
58
|
+
# streams_authorize= marks pgbus 0.18 (#524), which also fires
|
|
59
|
+
# pgbus:connected only after the seat joined (#523) and drops a
|
|
60
|
+
# closed tab at once (#520); neither of those has a signature.
|
|
61
|
+
config = ::Pgbus.configuration
|
|
62
|
+
%i[streams_presence_on_change= streams_presence_ttl streams_presence_patterns streams_authorize=].all? do
|
|
63
|
+
config.respond_to?(it)
|
|
64
|
+
end
|
|
65
|
+
rescue ::NameError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wired AND the seat table is migrated. seats_supported? is a query
|
|
70
|
+
# (pgbus memoizes a success), so only a render and the render job ask.
|
|
71
|
+
# A database error propagates: answering false would hide it.
|
|
72
|
+
def capable?
|
|
73
|
+
wired? && ::Pgbus::Streams::Presence.seats_supported?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# How often the transport's heartbeat touches a live tab (seconds):
|
|
77
|
+
# configuration only, no query, so a declaration can read it at
|
|
78
|
+
# class load.
|
|
79
|
+
def heartbeat_interval
|
|
80
|
+
config = ::Pgbus.configuration if Phlex::Reactive.pgbus? && ::Pgbus.respond_to?(:configuration)
|
|
81
|
+
interval = config.streams_heartbeat_interval if config.respond_to?(:streams_heartbeat_interval)
|
|
82
|
+
interval.is_a?(Numeric) ? interval : DEFAULT_HEARTBEAT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The stream name the transport (and every Turbo broadcast) uses for
|
|
86
|
+
# these parts.
|
|
87
|
+
# "<16 hex of the subject>:presence:<name>": the subject (the record,
|
|
88
|
+
# or a record-less roster's key) becomes a fixed-length digest of its
|
|
89
|
+
# stream name, so the key fits pgbus's queue-name budget (41 chars by
|
|
90
|
+
# default) whatever the model is called — the raw gid form, or pgbus's
|
|
91
|
+
# own "<param_key>_<digest>" fragment, overflows it for Document,
|
|
92
|
+
# Conversation, ProjectBoard, …. Leaves 15 chars for the presence
|
|
93
|
+
# name; ":presence:<name>" stays the suffix the patterns match. Not
|
|
94
|
+
# reversible: the render job finds the record through the row's note.
|
|
95
|
+
# pgbus raises ArgumentError when it still does not fit.
|
|
96
|
+
def stream_name(parts)
|
|
97
|
+
*subject, presence, name = parts
|
|
98
|
+
digest = Digest::SHA256.hexdigest(::Pgbus::Streams::Stream.name_from(subject))[0, DIGEST_LENGTH]
|
|
99
|
+
::Pgbus.stream_key!("#{digest}:#{presence}:#{name}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stream_name_budget = ::Pgbus.stream_name_budget
|
|
103
|
+
|
|
104
|
+
# Whether a presence name fits the key: pure configuration (the queue
|
|
105
|
+
# prefix), no database, so the declaration can check it.
|
|
106
|
+
def fits?(name) = DIGEST_LENGTH + SEPARATOR.length + name.to_s.length <= stream_name_budget
|
|
107
|
+
|
|
108
|
+
def over_budget_message(klass, name, detail = nil)
|
|
109
|
+
"#{klass.name}: the presence stream for reactive_presence :#{name} does not fit pgbus's " \
|
|
110
|
+
"stream-name budget of #{stream_name_budget} chars (a #{DIGEST_LENGTH}-char digest of the record, " \
|
|
111
|
+
"\":presence:\" and the name). Use a shorter presence name.#{" (#{detail})" if detail}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The signed SSE URL a <pgbus-stream-source> subscribes with.
|
|
115
|
+
def source_src(stream_name) = Phlex::Reactive::Defer.stream_src(stream_name)
|
|
116
|
+
|
|
117
|
+
# The rows of a stream: Hashes with "id" (the member), "connection_id",
|
|
118
|
+
# "seat", "state", "metadata" and "joined_at".
|
|
119
|
+
def members(stream_name) = ::Pgbus.stream(stream_name).presence.members
|
|
120
|
+
|
|
121
|
+
# Writes the state of ONE connection's row on the stream (pgbus
|
|
122
|
+
# shallow-merges it into what the row had).
|
|
123
|
+
def set_state(stream_name, connection_id:, state:)
|
|
124
|
+
::Pgbus.stream(stream_name).presence.set_state(connection_id:, state:)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# At boot: auto-join presence streams, and hear their changes. An
|
|
128
|
+
# app's own change hook keeps running (chained, first).
|
|
129
|
+
def install!(on_change)
|
|
130
|
+
config = ::Pgbus.configuration
|
|
131
|
+
patterns = config.streams_presence_patterns
|
|
132
|
+
patterns << PATTERN unless patterns.include?(PATTERN)
|
|
133
|
+
previous = config.streams_presence_on_change
|
|
134
|
+
return if previous.is_a?(ChainedHook)
|
|
135
|
+
|
|
136
|
+
config.streams_presence_on_change = ChainedHook.new(previous:, on_change:)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A component's ttl: — seconds a silent seat survives — keyed by its
|
|
140
|
+
# presence name, which every stream of that component ends with.
|
|
141
|
+
# pgbus takes an Integer (every stream) or a Hash of stream name /
|
|
142
|
+
# pattern => seconds with a :default; an Integer becomes the Hash's
|
|
143
|
+
# default so the app's own setting still covers every other stream.
|
|
144
|
+
def register_ttl(name, seconds)
|
|
145
|
+
config = ::Pgbus.configuration
|
|
146
|
+
ttl = config.streams_presence_ttl
|
|
147
|
+
config.streams_presence_ttl = ttl = { default: ttl } unless ttl.is_a?(Hash)
|
|
148
|
+
ttl.store(/:presence:#{Regexp.escape(name.to_s)}\z/, seconds)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Presence (issue #358): who is viewing a stream, one SEAT per browser tab,
|
|
6
|
+
# each with an ephemeral, declared state (the item it has selected), and a
|
|
7
|
+
# roster component that re-renders itself in every viewer's tab on join,
|
|
8
|
+
# leave, expiry and state change.
|
|
9
|
+
#
|
|
10
|
+
# class Boards::Viewers < Phlex::HTML
|
|
11
|
+
# include Phlex::Reactive::Component
|
|
12
|
+
#
|
|
13
|
+
# reactive_record :board
|
|
14
|
+
# reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
15
|
+
#
|
|
16
|
+
# def view_template
|
|
17
|
+
# div(**reactive_root) do
|
|
18
|
+
# presence.members.each do |member|
|
|
19
|
+
# span(**reactive_presence_member(member)) { member.metadata["name"] }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The transport does the membership (pgbus, zoolutions/pgbus#515): an SSE
|
|
26
|
+
# connection to a presence stream joins on connect and leaves on
|
|
27
|
+
# disconnect, a silent one expires after the stream's ttl, and each change
|
|
28
|
+
# calls the hook this module installs. The hook enqueues
|
|
29
|
+
# PresenceRenderJob, which re-renders the roster for everyone.
|
|
30
|
+
#
|
|
31
|
+
# Every pgbus call is in Presence::Transport. Without a capable transport
|
|
32
|
+
# (no pgbus, an older one, Action Cable) a presence root raises
|
|
33
|
+
# Phlex::Reactive::ConfigurationError at render: there is no degraded mode.
|
|
34
|
+
module Presence
|
|
35
|
+
# The reverse note a presence write leaves in the seat's state: the class
|
|
36
|
+
# that wrote it and the record it renders. A stream name cannot be turned
|
|
37
|
+
# back into a record (pgbus digests long names), so the render job reads
|
|
38
|
+
# this from any seat still on the stream. Never shown to the app: Peer#state
|
|
39
|
+
# holds only the declared keys.
|
|
40
|
+
NOTE_KEY = "_rp"
|
|
41
|
+
|
|
42
|
+
# A silent seat survives this many seconds (the transport's heartbeat,
|
|
43
|
+
# 15 s by default, keeps a live one fresh: two missed beats).
|
|
44
|
+
DEFAULT_TTL = 45
|
|
45
|
+
|
|
46
|
+
# Roster broadcasts coalesce: a page reload is a leave and a join within
|
|
47
|
+
# milliseconds, and a burst of selections is one morph per window.
|
|
48
|
+
COALESCE_MS = 150
|
|
49
|
+
|
|
50
|
+
# The client sends its presence source's connection id in this header, so
|
|
51
|
+
# a write lands on the row of THIS tab's presence connection — not on the
|
|
52
|
+
# first <pgbus-stream-source> on the page (X-Pgbus-Connection).
|
|
53
|
+
HEADER = "X-Reactive-Presence"
|
|
54
|
+
|
|
55
|
+
# What reactive_presence declared: the presence name, the state schema,
|
|
56
|
+
# the ttl, and the raw stream key parts of a record-less component.
|
|
57
|
+
Declaration = Data.define(:name, :state, :ttl, :key)
|
|
58
|
+
|
|
59
|
+
# One tab on the stream. `seat` is the tab's id (opaque; rendered by
|
|
60
|
+
# reactive_presence_peer so the client can find "me"), `state` the
|
|
61
|
+
# declared keys only. No connection id: it is the bearer key for writing
|
|
62
|
+
# the tab's row, so it stays inside the Roster.
|
|
63
|
+
Peer = Data.define(:seat, :member_id, :metadata, :state, :joined_at)
|
|
64
|
+
|
|
65
|
+
# One person: their seats, the newest one's metadata and state.
|
|
66
|
+
Member = Data.define(:id, :metadata, :seats) do
|
|
67
|
+
def state = seats.last&.state || {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Wired and migrated (a query: render time only). See Transport.
|
|
72
|
+
def capable? = Transport.capable?
|
|
73
|
+
|
|
74
|
+
# The actor's presence connection during an action (the HEADER), or nil.
|
|
75
|
+
def connection_id
|
|
76
|
+
Thread.current[:phlex_reactive_presence_connection]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def with_connection_id(connection_id)
|
|
80
|
+
previous = Thread.current[:phlex_reactive_presence_connection]
|
|
81
|
+
Thread.current[:phlex_reactive_presence_connection] = connection_id.presence
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
Thread.current[:phlex_reactive_presence_connection] = previous
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raise unless the transport can carry presence — at first render of a
|
|
88
|
+
# presence root, naming what is missing.
|
|
89
|
+
def require_capable!(klass)
|
|
90
|
+
return if capable?
|
|
91
|
+
|
|
92
|
+
raise Phlex::Reactive::ConfigurationError,
|
|
93
|
+
"#{klass.name} declares reactive_presence, which needs pgbus >= 0.18.1 (presence keyed by " \
|
|
94
|
+
"SSE connection, config.streams_authorize) with its presence table created or migrated " \
|
|
95
|
+
"(`rails g pgbus:add_presence`, `rails g pgbus:add_presence_seats`, or `rails pgbus:update`), " \
|
|
96
|
+
"and ActiveJob. Action Cable has no connection identity, so there is no fallback."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Boot (the engine's after_initialize): register the presence stream
|
|
100
|
+
# pattern and the change hook. Gated on the signatures alone, never a
|
|
101
|
+
# query — boot runs before a database exists. Nothing is touched
|
|
102
|
+
# without them: an app on Action Cable boots exactly as before.
|
|
103
|
+
def install!
|
|
104
|
+
Transport.install!(method(:changed)) if Transport.wired?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The transport's change hook (it runs on the SSE dispatcher thread):
|
|
108
|
+
# enqueue a roster render for a presence stream, nothing else.
|
|
109
|
+
def changed(stream_name, _change)
|
|
110
|
+
return unless Transport::PATTERN.match?(stream_name.to_s)
|
|
111
|
+
|
|
112
|
+
Phlex::Reactive::PresenceRenderJob.perform_later(stream_name.to_s)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The job's body: rebuild every roster component a seat on the stream
|
|
116
|
+
# names, render it as the table is NOW, and morph it into every viewer's
|
|
117
|
+
# tab (the joiner included: its first paint ran before its connection
|
|
118
|
+
# existed). Nobody left, or a gone record, renders nothing.
|
|
119
|
+
def render_roster(stream_name)
|
|
120
|
+
return unless capable?
|
|
121
|
+
|
|
122
|
+
# One render per class and record. A roster's reactive_state is shared
|
|
123
|
+
# markup under one id, the same for every viewer, so the state any
|
|
124
|
+
# note carries will do; per-viewer state belongs in presence state.
|
|
125
|
+
notes = Transport.members(stream_name).filter_map { it["state"]&.[](NOTE_KEY) }
|
|
126
|
+
notes.uniq { [it["c"], it["gid"]] }.each do
|
|
127
|
+
component = build(it, stream_name)
|
|
128
|
+
broadcast(component, exclude: nil) if component
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Morph `component`'s roster into its stream's viewers.
|
|
133
|
+
def broadcast(component, exclude:)
|
|
134
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
135
|
+
component.class, :replace, component, component, [component.reactive_presence_stream_name],
|
|
136
|
+
morph: true, target: nil, exclude:, visible_to: nil, coalesce: COALESCE_MS
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A roster component rebuilt from a note: the named class, only when it
|
|
141
|
+
# declares presence, with its record re-found (nil when it is gone).
|
|
142
|
+
def build(note, stream_name)
|
|
143
|
+
klass = note["c"].to_s.safe_constantize
|
|
144
|
+
return unless klass.respond_to?(:reactive_presence_declaration) && klass.reactive_presence_declaration
|
|
145
|
+
|
|
146
|
+
klass.from_identity(note.slice("gid", "s"))
|
|
147
|
+
rescue ::ActiveRecord::RecordNotFound
|
|
148
|
+
# The record is gone: there is nothing left to render.
|
|
149
|
+
nil
|
|
150
|
+
rescue Phlex::Reactive::Error => e
|
|
151
|
+
# The note cannot rebuild the component (no gid for one that needs
|
|
152
|
+
# its record): render nothing rather than fail the job on every retry.
|
|
153
|
+
Phlex::Reactive.send(:default_logger)&.warn(
|
|
154
|
+
"[phlex-reactive] presence stream #{stream_name}: cannot rebuild #{note["c"]} from its note " \
|
|
155
|
+
"(#{e.message}); its roster is not re-rendered"
|
|
156
|
+
)
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# A presence stream changed (issue #358): a tab joined, left, or expired.
|
|
6
|
+
# Re-render the roster as the table is when the job runs — not the change
|
|
7
|
+
# it was enqueued for — and morph it into every viewer's tab. A page
|
|
8
|
+
# reload (a leave, then a join within milliseconds) therefore renders the
|
|
9
|
+
# seat present, and coalescing folds the pair into one morph.
|
|
10
|
+
#
|
|
11
|
+
# Enqueued by Phlex::Reactive::Presence.changed, the transport's change
|
|
12
|
+
# hook, which runs on the SSE dispatcher thread: rendering there would hold
|
|
13
|
+
# it up. On defer_job_queue, like the push lane's render job.
|
|
14
|
+
#
|
|
15
|
+
# NOT eager-loaded (see the loader config in phlex/reactive.rb): the class
|
|
16
|
+
# body needs ActiveJob::Base, which isn't a gem dependency — the constant
|
|
17
|
+
# is only referenced behind Phlex::Reactive::Presence.capable?.
|
|
18
|
+
class PresenceRenderJob < ::ActiveJob::Base
|
|
19
|
+
queue_as { Phlex::Reactive.defer_job_queue }
|
|
20
|
+
|
|
21
|
+
def perform(stream_name)
|
|
22
|
+
Phlex::Reactive::Presence.render_roster(stream_name)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|