live_cable 0.0.1
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/app/assets/javascript/controllers/live_controller.js +123 -0
- data/app/assets/javascript/live_cable_blessing.js +20 -0
- data/app/assets/javascript/subscriptions.js +191 -0
- data/app/channels/live_channel.rb +45 -0
- data/app/helpers/live_cable_helper.rb +70 -0
- data/config/importmap.rb +5 -0
- data/lib/live_cable/component.rb +255 -0
- data/lib/live_cable/connection.rb +205 -0
- data/lib/live_cable/container.rb +103 -0
- data/lib/live_cable/csrf_checker.rb +20 -0
- data/lib/live_cable/delegation/array.rb +81 -0
- data/lib/live_cable/delegation/hash.rb +38 -0
- data/lib/live_cable/delegation/methods.rb +37 -0
- data/lib/live_cable/delegation/model.rb +12 -0
- data/lib/live_cable/delegator.rb +114 -0
- data/lib/live_cable/engine.rb +23 -0
- data/lib/live_cable/error.rb +5 -0
- data/lib/live_cable/model_observer.rb +13 -0
- data/lib/live_cable/observer.rb +44 -0
- data/lib/live_cable/observer_tracking.rb +71 -0
- data/lib/live_cable/render_context.rb +39 -0
- data/lib/live_cable.rb +47 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 22855e882f2fd803fffc8bf2614fd3aa4e1b47a3d0dd4a686a7d3adb90e79bba
|
|
4
|
+
data.tar.gz: 1c9522b46962dcb5cf0aa6336d2243edb162167344dc48c37820ef3a3a16154d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ee3630ec8ca212780062cbfc217d734c65d5b0ad558e2fb058858c1452e4f181ec5eba7b508e100921025683ffc44dc1b62c7a7163a0747c2e9f9bf6f27f8353
|
|
7
|
+
data.tar.gz: 8bc8de345fa73f1a67eefa9b59b09dfff59052758221c395a005ec4204708b4112724a355ec93f752c0758abd304d87fab3ca9321305262418213fecf79a4ce4
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import SubscriptionManager from "live_cable_subscriptions"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
defaults: Object,
|
|
7
|
+
status: String,
|
|
8
|
+
component: String,
|
|
9
|
+
liveId: String,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#subscription
|
|
13
|
+
#formDebounce
|
|
14
|
+
#reactiveDebounce
|
|
15
|
+
#reactiveDebouncedMessage
|
|
16
|
+
|
|
17
|
+
#callActionCallback = (event) => {
|
|
18
|
+
event.stopPropagation()
|
|
19
|
+
|
|
20
|
+
const { action, params } = event.detail
|
|
21
|
+
|
|
22
|
+
this.sendCall(action, params)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
this.element.addEventListener("call", this.#callActionCallback)
|
|
27
|
+
|
|
28
|
+
this.#subscription = SubscriptionManager.subscribe(
|
|
29
|
+
this.liveIdValue,
|
|
30
|
+
this.componentValue,
|
|
31
|
+
this.defaultsValue,
|
|
32
|
+
this
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
disconnect() {
|
|
37
|
+
this.element.removeEventListener("call", this.#callActionCallback)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
call({ params }) {
|
|
41
|
+
this.sendCall(params.action, params)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sendCall(action, params = {}) {
|
|
45
|
+
this.#subscription.send(
|
|
46
|
+
this.#unshiftDebounced(this.#callMessage(params, action))
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#callMessage(params, action) {
|
|
51
|
+
return {
|
|
52
|
+
_action: action,
|
|
53
|
+
params: new URLSearchParams(params).toString(),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reactive({ target }) {
|
|
58
|
+
this.sendReactive(target)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sendReactive(target) {
|
|
62
|
+
this.#subscription.send(
|
|
63
|
+
this.#unshiftDebounced(this.#reactiveMessage(target))
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#reactiveMessage(target) {
|
|
68
|
+
return {
|
|
69
|
+
_action: '_reactive',
|
|
70
|
+
name: target.name,
|
|
71
|
+
value: target.value,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#unshiftDebounced(message) {
|
|
76
|
+
const messages = [message]
|
|
77
|
+
|
|
78
|
+
if (this.#reactiveDebouncedMessage) {
|
|
79
|
+
messages.unshift(this.#reactiveDebouncedMessage)
|
|
80
|
+
this.#reactiveDebouncedMessage = null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { messages, _csrf_token: this.#csrfToken }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
reactiveDebounce(event) {
|
|
87
|
+
clearTimeout(this.#reactiveDebounce)
|
|
88
|
+
this.#reactiveDebouncedMessage = this.#reactiveMessage(event.target)
|
|
89
|
+
|
|
90
|
+
this.#reactiveDebounce = setTimeout(() => {
|
|
91
|
+
this.#reactiveDebouncedMessage = null
|
|
92
|
+
this.reactive(event)
|
|
93
|
+
}, event.params.debounce || 200)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
form({ currentTarget, params: { action } }) {
|
|
97
|
+
this.sendForm(action, currentTarget)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
sendForm(action, formEl) {
|
|
101
|
+
// Clear reactive debounce so it doesn't fire after form
|
|
102
|
+
clearTimeout(this.#reactiveDebounce)
|
|
103
|
+
clearTimeout(this.#formDebounce)
|
|
104
|
+
|
|
105
|
+
const formData = new FormData(formEl)
|
|
106
|
+
const params = new URLSearchParams(formData).toString()
|
|
107
|
+
|
|
108
|
+
this.#subscription.send(
|
|
109
|
+
this.#unshiftDebounced(this.#callMessage(params, action))
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
formDebounce(event) {
|
|
114
|
+
clearTimeout(this.#formDebounce)
|
|
115
|
+
this.#formDebounce = setTimeout(() => {
|
|
116
|
+
this.form(event)
|
|
117
|
+
}, event.params.debounce || 200)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get #csrfToken() {
|
|
121
|
+
return document.querySelector("meta[name='csrf-token']")?.getAttribute("content")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import LiveController from "live_cable_controller"
|
|
2
|
+
|
|
3
|
+
export default function LiveCableBlessing(constructor) {
|
|
4
|
+
Object.assign(constructor.prototype, {
|
|
5
|
+
liveCableAction(action, params = {}) {
|
|
6
|
+
this.context.logDebugActivity("liveCableAction", {
|
|
7
|
+
action,
|
|
8
|
+
params,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
this.dispatch('call', {
|
|
12
|
+
detail: {
|
|
13
|
+
action,
|
|
14
|
+
params,
|
|
15
|
+
},
|
|
16
|
+
prefix: null,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveCable Subscription Manager
|
|
3
|
+
*
|
|
4
|
+
* This module implements subscription persistence for LiveCable components.
|
|
5
|
+
* Instead of creating new ActionCable subscriptions every time a Stimulus controller
|
|
6
|
+
* connects/disconnects, we maintain a single subscription per component instance
|
|
7
|
+
* (identified by liveId) and simply update the controller reference.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* - SubscriptionManager: Singleton that manages all active subscriptions
|
|
11
|
+
* - Subscription: Wraps an ActionCable subscription and handles morphdom updates
|
|
12
|
+
* - Controller reconnection: When a controller disconnects/reconnects (e.g., due to
|
|
13
|
+
* Turbo navigation), the subscription persists and just updates its controller reference
|
|
14
|
+
*
|
|
15
|
+
* Benefits:
|
|
16
|
+
* - Reduces WebSocket churn
|
|
17
|
+
* - Maintains server-side state across page transitions
|
|
18
|
+
* - Eliminates race conditions from rapid connect/disconnect cycles
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createConsumer } from "@rails/actioncable"
|
|
22
|
+
import morphdom from "morphdom"
|
|
23
|
+
|
|
24
|
+
const consumer = createConsumer()
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Manages all LiveCable subscriptions across the application.
|
|
28
|
+
* Ensures that each component (identified by liveId) has at most one
|
|
29
|
+
* active ActionCable subscription at any time.
|
|
30
|
+
*/
|
|
31
|
+
class SubscriptionManager {
|
|
32
|
+
/** @type {Object.<string, Subscription>} */
|
|
33
|
+
#subscriptions = {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to or reconnect to a LiveCable component.
|
|
37
|
+
* If a subscription already exists for this liveId, updates the controller
|
|
38
|
+
* reference instead of creating a new subscription.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} liveId - Unique identifier for the component instance
|
|
41
|
+
* @param {string} component - Component class name (e.g., "counter")
|
|
42
|
+
* @param {Object} defaults - Default values for reactive variables
|
|
43
|
+
* @param {Object} controller - Stimulus controller instance
|
|
44
|
+
* @returns {Subscription} The subscription instance
|
|
45
|
+
*/
|
|
46
|
+
subscribe(liveId, component, defaults, controller) {
|
|
47
|
+
if (!this.#subscriptions[liveId]) {
|
|
48
|
+
this.#subscriptions[liveId] = new Subscription(liveId, component, defaults, controller)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.#subscriptions[liveId].controller = controller
|
|
52
|
+
|
|
53
|
+
return this.#subscriptions[liveId]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove a subscription from the manager.
|
|
58
|
+
* Called when the server sends a 'destroy' status, indicating the
|
|
59
|
+
* component instance should be permanently removed.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} liveId - Unique identifier for the component instance
|
|
62
|
+
*/
|
|
63
|
+
unsubscribe(liveId) {
|
|
64
|
+
delete this.#subscriptions[liveId]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Represents a single ActionCable subscription to a LiveCable component.
|
|
70
|
+
* Handles receiving updates from the server and applying them to the DOM
|
|
71
|
+
* via morphdom.
|
|
72
|
+
*/
|
|
73
|
+
class Subscription {
|
|
74
|
+
/** @type {string} */
|
|
75
|
+
#liveId
|
|
76
|
+
/** @type {string} */
|
|
77
|
+
#component
|
|
78
|
+
/** @type {Object} */
|
|
79
|
+
#defaults
|
|
80
|
+
/** @type {Object|null} */
|
|
81
|
+
#controller
|
|
82
|
+
/** @type {Object} */
|
|
83
|
+
#subscription
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a new subscription to a LiveCable component.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} liveId - Unique identifier for the component instance
|
|
89
|
+
* @param {string} component - Component class name (e.g., "counter")
|
|
90
|
+
* @param {Object} defaults - Default values for reactive variables
|
|
91
|
+
* @param {Object} controller - Stimulus controller instance
|
|
92
|
+
*/
|
|
93
|
+
constructor(liveId, component, defaults, controller) {
|
|
94
|
+
this.#liveId = liveId
|
|
95
|
+
this.#component = component
|
|
96
|
+
this.#defaults = defaults
|
|
97
|
+
this.#controller = controller
|
|
98
|
+
this.#subscribe()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Update the controller reference.
|
|
103
|
+
* Called when a Stimulus controller reconnects to an existing subscription.
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} controller - Stimulus controller instance
|
|
106
|
+
*/
|
|
107
|
+
set controller(controller) {
|
|
108
|
+
this.#controller = controller
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send a message to the server through the ActionCable subscription.
|
|
113
|
+
*
|
|
114
|
+
* @param {Object} message - Message to send (e.g., action calls, reactive updates)
|
|
115
|
+
*/
|
|
116
|
+
send(message) {
|
|
117
|
+
this.#subscription.send(message)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create the underlying ActionCable subscription.
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
#subscribe() {
|
|
125
|
+
this.#subscription = consumer.subscriptions.create({
|
|
126
|
+
channel: "LiveChannel",
|
|
127
|
+
component: this.#component,
|
|
128
|
+
defaults: this.#defaults,
|
|
129
|
+
live_id: this.#liveId,
|
|
130
|
+
}, {
|
|
131
|
+
received: this.#received,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle incoming messages from the server.
|
|
137
|
+
* Processes status updates and DOM refreshes.
|
|
138
|
+
*
|
|
139
|
+
* @param {Object} data - Data received from the server
|
|
140
|
+
* @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
|
|
141
|
+
* @param {string} [data._refresh] - HTML to morph into the DOM
|
|
142
|
+
* @private
|
|
143
|
+
*/
|
|
144
|
+
#received = (data) => {
|
|
145
|
+
// Handle destroy status - permanently remove this subscription
|
|
146
|
+
if (data['_status'] === 'destroy') {
|
|
147
|
+
this.#subscription.unsubscribe()
|
|
148
|
+
subscriptionManager.unsubscribe(this.#liveId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// If no controller is attached, we can't update the DOM
|
|
152
|
+
if (!this.#controller) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update connection status
|
|
157
|
+
if (data['_status']) {
|
|
158
|
+
this.#controller.statusValue = data['_status']
|
|
159
|
+
}
|
|
160
|
+
// Apply DOM updates via morphdom
|
|
161
|
+
else if (data['_refresh']) {
|
|
162
|
+
morphdom(this.#controller.element, data['_refresh'], {
|
|
163
|
+
// Preserve elements marked with live-ignore attribute
|
|
164
|
+
onBeforeElUpdated(fromEl, toEl) {
|
|
165
|
+
if (!fromEl.hasAttribute) {
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return !fromEl.hasAttribute('live-ignore')
|
|
170
|
+
},
|
|
171
|
+
// Use stable keys for better morphing performance and state preservation
|
|
172
|
+
getNodeKey(node) {
|
|
173
|
+
if (!node) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (node.getAttribute) {
|
|
178
|
+
return node.getAttribute('live-key') ||
|
|
179
|
+
node.getAttribute('data-live-live-io-value') ||
|
|
180
|
+
node.getAttribute('id') ||
|
|
181
|
+
node.id
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const subscriptionManager = new SubscriptionManager()
|
|
190
|
+
|
|
191
|
+
export default subscriptionManager
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LiveChannel < ActionCable::Channel::Base
|
|
4
|
+
def subscribed
|
|
5
|
+
instance = params[:live_id].present? && live_connection.get_component(params[:live_id])
|
|
6
|
+
rendered = instance.present?
|
|
7
|
+
|
|
8
|
+
unless instance
|
|
9
|
+
instance = LiveCable.instance_from_string(params[:component], params[:live_id])
|
|
10
|
+
live_connection.add_component(instance)
|
|
11
|
+
instance.defaults = params[:defaults]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
stream_from(instance.channel_name)
|
|
15
|
+
|
|
16
|
+
instance.channel = self
|
|
17
|
+
instance.connected # @todo - Should this be called multiple times?
|
|
18
|
+
instance.broadcast_subscribe
|
|
19
|
+
instance.render_broadcast unless rendered
|
|
20
|
+
|
|
21
|
+
live_connection.set_channel(instance, self)
|
|
22
|
+
|
|
23
|
+
@component = instance
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def receive(data)
|
|
27
|
+
live_connection.receive(component, data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unsubscribed
|
|
31
|
+
return unless component
|
|
32
|
+
|
|
33
|
+
stop_stream_from(component.channel_name)
|
|
34
|
+
component.disconnected
|
|
35
|
+
live_connection.remove_component(component)
|
|
36
|
+
live_connection.remove_channel(component)
|
|
37
|
+
component.live_connection = nil
|
|
38
|
+
@component = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @return [LiveCable::Component, nil]
|
|
44
|
+
attr_reader :component
|
|
45
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCableHelper
|
|
4
|
+
def live_component(component, **, &block)
|
|
5
|
+
tag.div(**live_attributes(component, component.defaults, **)) do
|
|
6
|
+
capture { block.call }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @param [LiveCable::Component] component
|
|
11
|
+
def with_render_context(component, &)
|
|
12
|
+
context = LiveCable::RenderContext.new(component)
|
|
13
|
+
context_stack.push(context)
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
value = yield
|
|
17
|
+
ensure
|
|
18
|
+
context_stack.pop
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
[value, context]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def live(component, **options)
|
|
25
|
+
renderable = component
|
|
26
|
+
id = options.delete(:id)
|
|
27
|
+
|
|
28
|
+
if renderable.is_a?(String)
|
|
29
|
+
live_id = "#{renderable}/#{id}"
|
|
30
|
+
|
|
31
|
+
renderable = if (existing = render_context&.get_component(live_id))
|
|
32
|
+
existing
|
|
33
|
+
else
|
|
34
|
+
LiveCable.instance_from_string(renderable, id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @todo Move to live_component so direct renders work too, we would need to assign the options
|
|
39
|
+
# again when a connection is assigned so it no loner uses the local container
|
|
40
|
+
render_context&.add_component(renderable)
|
|
41
|
+
|
|
42
|
+
renderable.defaults = options
|
|
43
|
+
|
|
44
|
+
render(renderable)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def live_attributes(component, defaults = {}, **options)
|
|
48
|
+
options.merge(
|
|
49
|
+
{
|
|
50
|
+
data: {
|
|
51
|
+
controller: "live #{options.with_indifferent_access.dig(:data, :controller)}".rstrip,
|
|
52
|
+
live_defaults_value: defaults.to_json,
|
|
53
|
+
live_component_value: component.class.component_string,
|
|
54
|
+
live_live_id_value: component.live_id,
|
|
55
|
+
live_status_value: component.status,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def context_stack
|
|
64
|
+
@context_stack ||= []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_context
|
|
68
|
+
context_stack.last
|
|
69
|
+
end
|
|
70
|
+
end
|