live_cable 0.2.0 → 0.3.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 +98 -0
- data/README.md +13 -15
- data/app/assets/javascript/controllers/live_controller.js +3 -6
- data/app/assets/javascript/subscriptions.js +43 -3
- data/app/channels/live_channel.rb +39 -16
- data/lib/live_cable/action_cable_connection.rb +19 -0
- data/lib/live_cable/component/broadcasting.rb +3 -1
- data/lib/live_cable/component/identification.rb +0 -4
- data/lib/live_cable/component/lifecycle.rb +0 -1
- data/lib/live_cable/component/streaming.rb +0 -7
- data/lib/live_cable/connection/error_handling.rb +27 -10
- data/lib/live_cable/connection/messaging.rb +0 -15
- data/lib/live_cable/connection.rb +0 -6
- data/lib/live_cable/engine.rb +6 -0
- data/lib/live_cable/testing/test_channel.rb +10 -1
- data/lib/live_cable/testing/test_component.rb +3 -14
- data/lib/live_cable/testing.rb +2 -3
- data/lib/live_cable/version.rb +1 -1
- metadata +2 -3
- data/lib/live_cable/connection/channel_management.rb +0 -13
- data/lib/live_cable/csrf_checker.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9eab70af0621e69a52c7eaccce2d76a400a8627223c6aa91525230d262179782
|
|
4
|
+
data.tar.gz: 1a6ca70d53e728fc4893607bc5de7ab12094bbdefb06d52cd2a83afefd653ac5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b43cf0bd548271aeae7534a26d1d950266078f3f571165fb99d5daec8021d1c8af16b29f2b71866aa6030594b5e829ecc59bcf4186b4209666472f81d3b7c9bf
|
|
7
|
+
data.tar.gz: 3d7753820b0ff173ae72a210eb4ecfbf77704e1897a2ebf570c085e7bad6ec1a7531bb7138beb64cf0fadd2af39ee77cf6be0f0e493961d770cd11182e2d3a43
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,104 @@ The Ruby gem (`live_cable`) and the npm package (`@isometriks/live_cable`) are
|
|
|
6
6
|
released together and share a single version number. Entries below note which
|
|
7
7
|
side of the pair a change affects when it isn't both.
|
|
8
8
|
|
|
9
|
+
## 0.3.0 - 2026-09-26
|
|
10
|
+
|
|
11
|
+
### Upgrading from 0.2
|
|
12
|
+
|
|
13
|
+
Upgrade the gem and the npm package together. The 0.3 client no longer sends
|
|
14
|
+
the `_csrf_token` field that the 0.2 gem checks on every message, so a newer
|
|
15
|
+
client talking to an older gem has every action refused.
|
|
16
|
+
|
|
17
|
+
If your `ApplicationCable::Connection` follows the 0.2 installation guide,
|
|
18
|
+
delete both LiveCable lines from it. LiveCable now attaches `live_connection`
|
|
19
|
+
to every connection itself.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# app/channels/application_cable/connection.rb - delete these
|
|
23
|
+
identified_by :live_connection
|
|
24
|
+
|
|
25
|
+
def connect
|
|
26
|
+
self.live_connection = LiveCable::Connection.new(request)
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Delete them together. Without `identified_by` there is no `live_connection=`
|
|
31
|
+
for `connect` to call and every handshake fails. Without the `connect` override
|
|
32
|
+
the identifier shadows the `live_connection` LiveCable attaches with a nil one,
|
|
33
|
+
and every subscribe fails with a `LiveCable::Error` that says so. Leaving both
|
|
34
|
+
in place keeps working, at the cost described under *Changed*.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **A message could be refused with no answer, leaving the component stuck in
|
|
39
|
+
its loading state.** `LiveChannel#receive` had no rescue of its own, so
|
|
40
|
+
anything raised before an action's own rescue — a component that failed to
|
|
41
|
+
subscribe, the CSRF check — went to ActionCable, which only logs it. The
|
|
42
|
+
client holds `live-loading` and `live-disable-with` until it hears back, so
|
|
43
|
+
the button stayed disabled until a reload. Every batch is now answered with a
|
|
44
|
+
`_refresh`, `_ack` or `_error`. A subscribe that fails at any point transmits
|
|
45
|
+
an `_error` too, and the client's unsubscribe then removes the half-built
|
|
46
|
+
component from the connection rather than leaving it for the next subscribe
|
|
47
|
+
to resurrect (gem).
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- `live_connection` is attached to every `ActionCable::Connection` by LiveCable
|
|
52
|
+
itself, so the connection no longer declares it. As an identifier, the
|
|
53
|
+
per-socket `LiveCable::Connection` became part of the connection's identity,
|
|
54
|
+
which `ActionCable.server.remote_connections` has to match in full, so an
|
|
55
|
+
application could never disconnect its users' sockets on sign-out. See
|
|
56
|
+
*Upgrading from 0.2* above (gem).
|
|
57
|
+
|
|
58
|
+
### Removed
|
|
59
|
+
|
|
60
|
+
- The per-message CSRF token check, and with it the `_csrf_token` field the
|
|
61
|
+
client sent with every batch. A WebSocket is protected at its handshake by
|
|
62
|
+
ActionCable's origin check and the `SameSite=Lax` session cookie, and the
|
|
63
|
+
check added nothing on top of those except a way to wedge a socket: it ran
|
|
64
|
+
against the session captured at the handshake, and a socket never sees
|
|
65
|
+
cookies set afterwards, so once the session's token rotated — Devise does
|
|
66
|
+
this on every sign-in — every message on that socket was refused until the
|
|
67
|
+
socket reconnected. A socket that outlives a sign-in is handled as for any
|
|
68
|
+
other channel, by the application disconnecting it; see the architecture
|
|
69
|
+
guide (gem + npm).
|
|
70
|
+
|
|
71
|
+
## 0.2.1 - 2026-08-13
|
|
72
|
+
|
|
73
|
+
### Removed
|
|
74
|
+
|
|
75
|
+
- `Component#channel_name` and `Connection#channel_name`. A component no longer
|
|
76
|
+
subscribes to a stream of its own now that payloads are written straight to its
|
|
77
|
+
channel, so the name identified a stream nothing published to or read from.
|
|
78
|
+
Subscribing to external streams with `stream_from` is unaffected (gem).
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- **The opening payload from `LiveChannel#subscribed` could be dropped.**
|
|
83
|
+
Payloads were published through the pubsub adapter, but ActionCable registers
|
|
84
|
+
`stream_from` asynchronously — so the initial render could be published before
|
|
85
|
+
the subscription it targets existed, and pubsub delivers only to subscribers
|
|
86
|
+
present at that moment, with no buffering or retry. The component was left
|
|
87
|
+
showing `data-live-status-value="disconnected"`, with no cached render on the
|
|
88
|
+
client for a Turbo reattach to replay, until some later action happened to
|
|
89
|
+
produce a refresh. When a subscribe render raised, the `_error` payload was
|
|
90
|
+
lost outright and the failure never surfaced in the browser at all. A
|
|
91
|
+
component's stream belongs to exactly one connection, so payloads are now
|
|
92
|
+
written straight to that connection rather than published, which removes the
|
|
93
|
+
race along with a pubsub round trip (gem).
|
|
94
|
+
- **Components not reattaching after a Turbo Drive navigation.** Navigating to a
|
|
95
|
+
page containing a component that is already subscribed deliberately keeps the
|
|
96
|
+
existing subscription, so `LiveChannel#subscribed` does not run again and the
|
|
97
|
+
server sends nothing. The newly rendered element was left with its
|
|
98
|
+
server-rendered status of `disconnected` and never received the component's
|
|
99
|
+
current state. A reconnecting controller now re-syncs the subscription's status
|
|
100
|
+
and replays the last render into the new element (npm).
|
|
101
|
+
- Building DOM from a server render used iterator helpers
|
|
102
|
+
(`childNodes.values().find(...)`), an ES2025 feature, which raised
|
|
103
|
+
`TypeError: ... .find is not a function` on runtimes that do not implement them
|
|
104
|
+
— Node 20 and earlier, and browsers older than Chrome 122 / Firefox 131 /
|
|
105
|
+
Safari 18.4. Rewritten with `Array.from` (npm).
|
|
106
|
+
|
|
9
107
|
## 0.2.0
|
|
10
108
|
|
|
11
109
|
The gem and npm package versions are realigned in this release. The npm package
|
data/README.md
CHANGED
|
@@ -30,21 +30,19 @@ bundle install
|
|
|
30
30
|
|
|
31
31
|
## Configuration
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
```
|
|
33
|
+
There is nothing to configure on the cable side. LiveCable attaches itself to
|
|
34
|
+
`ActionCable::Connection::Base`, so your `ApplicationCable::Connection` stays as
|
|
35
|
+
it is, with whatever `identified_by` it declares or none at all. LiveCable needs
|
|
36
|
+
no identifiers of its own.
|
|
37
|
+
|
|
38
|
+
If you followed an earlier version of this guide, remove the
|
|
39
|
+
`identified_by :live_connection` line and the `connect` override that went with
|
|
40
|
+
it. Remove both at once, since either one on its own breaks every connection.
|
|
41
|
+
Together they keep working, but they make a per-socket object part of the
|
|
42
|
+
connection's identity, which stops `ActionCable.server.remote_connections` from
|
|
43
|
+
ever finding your users' sockets. See the
|
|
44
|
+
[architecture guide](https://livecable.io/guide/architecture#sign-in-and-sign-out)
|
|
45
|
+
for what to do about sockets when a user signs in or out.
|
|
48
46
|
|
|
49
47
|
## JavaScript Setup
|
|
50
48
|
|
|
@@ -166,20 +166,17 @@ export default class extends Controller {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// Batch any pending debounced messages ahead of this one so they are sent
|
|
170
|
+
// in the order they were triggered
|
|
169
171
|
#flushDebounced(message) {
|
|
170
172
|
const messages = [message]
|
|
171
173
|
|
|
172
|
-
// Add all pending debounced messages to be sent immediately
|
|
173
174
|
for (const [source, { timeout, message: debouncedMessage }] of this.#debounces) {
|
|
174
175
|
clearTimeout(timeout)
|
|
175
176
|
messages.unshift(debouncedMessage)
|
|
176
177
|
}
|
|
177
178
|
this.#debounces.clear()
|
|
178
179
|
|
|
179
|
-
return { messages
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
get #csrfToken() {
|
|
183
|
-
return document.querySelector("meta[name='csrf-token']")?.getAttribute("content")
|
|
180
|
+
return { messages }
|
|
184
181
|
}
|
|
185
182
|
}
|
|
@@ -34,8 +34,7 @@ function createDOMFromHTML(html) {
|
|
|
34
34
|
template.innerHTML = html
|
|
35
35
|
|
|
36
36
|
// Find a node that isn't a comment
|
|
37
|
-
const node = template.content.childNodes
|
|
38
|
-
.values()
|
|
37
|
+
const node = Array.from(template.content.childNodes)
|
|
39
38
|
.find(n => n.nodeName !== '#comment')
|
|
40
39
|
|
|
41
40
|
if (node) {
|
|
@@ -230,6 +229,15 @@ class ComponentState {
|
|
|
230
229
|
return this.#element
|
|
231
230
|
}
|
|
232
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Whether a render has been received and cached, and can therefore be
|
|
234
|
+
* replayed into a freshly attached element.
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
get hasRender() {
|
|
238
|
+
return this.#lastTemplate !== null && Boolean(this.#partsByTemplate[this.#lastTemplate])
|
|
239
|
+
}
|
|
240
|
+
|
|
233
241
|
/**
|
|
234
242
|
* Create a DOM element from stored state.
|
|
235
243
|
* @param {Object} refresh - Optional refresh data to update state
|
|
@@ -345,12 +353,44 @@ class Subscription {
|
|
|
345
353
|
|
|
346
354
|
/**
|
|
347
355
|
* Update the controller reference.
|
|
348
|
-
* Called when a Stimulus controller reconnects to an existing subscription
|
|
356
|
+
* Called when a Stimulus controller reconnects to an existing subscription —
|
|
357
|
+
* most importantly after a Turbo Drive navigation to a page that contains the
|
|
358
|
+
* same component, where prune() deliberately keeps the subscription alive.
|
|
359
|
+
*
|
|
360
|
+
* In that case the ActionCable subscription is never recreated, so
|
|
361
|
+
* LiveChannel#subscribed does not run again and the server sends nothing.
|
|
362
|
+
* Without re-syncing here, the newly rendered element keeps the
|
|
363
|
+
* server-rendered status of "disconnected" forever and never receives the
|
|
364
|
+
* component's current state.
|
|
349
365
|
*
|
|
350
366
|
* @param {Object} controller - Stimulus controller instance
|
|
351
367
|
*/
|
|
352
368
|
set controller(controller) {
|
|
369
|
+
const previous = this.#controller
|
|
370
|
+
|
|
353
371
|
this.#controller = controller
|
|
372
|
+
this.#componentState.element = controller.element
|
|
373
|
+
|
|
374
|
+
if (previous && previous !== controller) {
|
|
375
|
+
this.#reattach()
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Bring a freshly connected controller up to date with state this
|
|
381
|
+
* subscription already holds.
|
|
382
|
+
* @private
|
|
383
|
+
*/
|
|
384
|
+
#reattach() {
|
|
385
|
+
if (this.#currentStatus) {
|
|
386
|
+
this.#controller.statusValue = this.#currentStatus
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Replay the last render into the new element. The server-side component
|
|
390
|
+
// is the same instance, so the cached parts are its current state.
|
|
391
|
+
if (this.#componentState.hasRender) {
|
|
392
|
+
this.#handleRefresh(null)
|
|
393
|
+
}
|
|
354
394
|
}
|
|
355
395
|
|
|
356
396
|
/**
|
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class LiveChannel < ActionCable::Channel::Base
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Private so ActionCable does not expose it as an action the client can call
|
|
5
|
+
delegate :live_connection, to: :connection, private: true
|
|
6
|
+
|
|
7
|
+
before_subscribe :ensure_live_connection
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
# @component is assigned as soon as the component is known, so a failure
|
|
10
|
+
# later in here still leaves it for unsubscribed to clean up
|
|
11
|
+
def subscribed
|
|
8
12
|
live_id = "#{params[:component]}/#{params[:id]}"
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
rendered =
|
|
14
|
+
@component = live_connection.get_component(live_id)
|
|
15
|
+
rendered = component.present?
|
|
12
16
|
|
|
13
|
-
unless
|
|
14
|
-
|
|
15
|
-
live_connection.add_component(
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
unless component
|
|
18
|
+
@component = LiveCable.instance_from_string(params[:component], params[:id])
|
|
19
|
+
live_connection.add_component(component)
|
|
20
|
+
component.defaults = params[:defaults]
|
|
21
|
+
component.apply_defaults
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
component.connect(self)
|
|
21
25
|
|
|
22
26
|
if rendered
|
|
23
|
-
|
|
27
|
+
component.broadcast_subscribe
|
|
24
28
|
else
|
|
25
|
-
|
|
29
|
+
component.broadcast_render
|
|
26
30
|
end
|
|
27
|
-
|
|
28
|
-
@component = instance
|
|
29
31
|
rescue StandardError => error
|
|
30
|
-
live_connection.handle_error(
|
|
32
|
+
live_connection.handle_error(component, error, channel: self)
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
# Every batch must be answered - the client holds its loading state until a
|
|
36
|
+
# _refresh, _ack or _error arrives - so nothing raised here may escape to
|
|
37
|
+
# ActionCable, which would only log it and leave the client hanging.
|
|
33
38
|
def receive(data)
|
|
39
|
+
raise LiveCable::Error, 'No component was built, so this subscription cannot receive messages' unless component
|
|
40
|
+
|
|
34
41
|
live_connection.receive(component, data)
|
|
42
|
+
rescue StandardError => error
|
|
43
|
+
live_connection.handle_error(component, error, channel: self)
|
|
35
44
|
end
|
|
36
45
|
|
|
37
46
|
def unsubscribed
|
|
@@ -41,8 +50,22 @@ class LiveChannel < ActionCable::Channel::Base
|
|
|
41
50
|
@component = nil
|
|
42
51
|
end
|
|
43
52
|
|
|
53
|
+
# Exposes #transmit, which ActionCable keeps private to the channel.
|
|
54
|
+
def broadcast(data)
|
|
55
|
+
transmit(data)
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
private
|
|
45
59
|
|
|
46
60
|
# @return [LiveCable::Component, nil]
|
|
47
61
|
attr_reader :component
|
|
62
|
+
|
|
63
|
+
def ensure_live_connection
|
|
64
|
+
return if live_connection
|
|
65
|
+
|
|
66
|
+
raise LiveCable::Error, "#{connection.class.name} still declares identified_by :live_connection, " \
|
|
67
|
+
'which shadows the live_connection LiveCable attaches and leaves it nil. ' \
|
|
68
|
+
'Remove that line and the connect override that set it; see the 0.3.0 ' \
|
|
69
|
+
'upgrade notes.'
|
|
70
|
+
end
|
|
48
71
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
# Mixed into ActionCable::Connection::Base by the engine, so every socket
|
|
5
|
+
# carries its LiveCable::Connection without the application declaring
|
|
6
|
+
# anything.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately not an identifier. The identified_by values make up a
|
|
9
|
+
# connection's identity, which remote_connections.where(...) has to match in
|
|
10
|
+
# full to disconnect a socket, and a per-socket object can never be named
|
|
11
|
+
# there. Keeping LiveCable off the identifiers leaves that mechanism working
|
|
12
|
+
# for the application's own, such as current_user.
|
|
13
|
+
module ActionCableConnection
|
|
14
|
+
# @return [LiveCable::Connection]
|
|
15
|
+
def live_connection
|
|
16
|
+
@live_connection ||= LiveCable::Connection.new(request)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -5,8 +5,10 @@ module LiveCable
|
|
|
5
5
|
module Broadcasting
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
# Nil until the component's own controller subscribes; children rendered
|
|
9
|
+
# by a parent have no channel to deliver to yet.
|
|
8
10
|
def broadcast(data)
|
|
9
|
-
|
|
11
|
+
channel&.broadcast(data)
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def broadcast_subscribe
|
|
@@ -7,14 +7,7 @@ module LiveCable
|
|
|
7
7
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
|
-
def start_stream
|
|
11
|
-
channel.stream_from(channel_name)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
10
|
def stop_stream
|
|
15
|
-
channel.stop_stream_from(channel_name)
|
|
16
|
-
|
|
17
|
-
# Stop all additional streams started via stream_from
|
|
18
11
|
additional_streams.each do |stream_name|
|
|
19
12
|
channel.stop_stream_from(stream_name)
|
|
20
13
|
end
|
|
@@ -5,11 +5,35 @@ module LiveCable
|
|
|
5
5
|
module ErrorHandling
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Report an error and replace the component on the client with an
|
|
9
|
+
# error box.
|
|
10
|
+
#
|
|
11
|
+
# @param component [LiveCable::Component, nil] nil when the failure
|
|
12
|
+
# happened before a component existed, such as a subscribe that could
|
|
13
|
+
# not build one
|
|
14
|
+
# @param error [Exception]
|
|
15
|
+
# @param channel [#broadcast, nil] the channel to deliver the _error
|
|
16
|
+
# through; needed when the component failed before it connected to
|
|
17
|
+
# one, or when there is no component at all
|
|
18
|
+
def handle_error(component, error, channel: nil)
|
|
9
19
|
Rails.error.report(error)
|
|
10
20
|
|
|
21
|
+
html = error_html(component, error)
|
|
22
|
+
|
|
23
|
+
# Destroy children first so their _status:destroy messages arrive before _error
|
|
24
|
+
component&.rendered_children&.each(&:destroy)
|
|
25
|
+
|
|
26
|
+
# Broadcast the error - JS replaces the DOM and calls unsubscribe(),
|
|
27
|
+
# which triggers LiveChannel#unsubscribed -> component.disconnect for server cleanup
|
|
28
|
+
(channel || component)&.broadcast(_error: html)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def error_html(component, error)
|
|
11
34
|
if LiveCable.configuration.verbose_errors
|
|
12
|
-
|
|
35
|
+
name = component ? component.class.name : 'LiveCable'
|
|
36
|
+
summary = "#{name} - #{error.class.name}: #{ERB::Util.html_escape(error.message)}"
|
|
13
37
|
backtrace_html = <<~HTML
|
|
14
38
|
<small>
|
|
15
39
|
<ol>
|
|
@@ -21,19 +45,12 @@ module LiveCable
|
|
|
21
45
|
summary = 'An error occurred'
|
|
22
46
|
end
|
|
23
47
|
|
|
24
|
-
|
|
48
|
+
<<~HTML
|
|
25
49
|
<details>
|
|
26
50
|
<summary style="color: #f00; cursor: pointer">#{summary}</summary>
|
|
27
51
|
#{backtrace_html}
|
|
28
52
|
</details>
|
|
29
53
|
HTML
|
|
30
|
-
|
|
31
|
-
# Destroy children first so their _status:destroy messages arrive before _error
|
|
32
|
-
component.rendered_children.each(&:destroy)
|
|
33
|
-
|
|
34
|
-
# Broadcast the error - JS replaces the DOM and calls unsubscribe(),
|
|
35
|
-
# which triggers LiveChannel#unsubscribed -> component.disconnect for server cleanup
|
|
36
|
-
component.broadcast(_error: html)
|
|
37
54
|
end
|
|
38
55
|
end
|
|
39
56
|
end
|
|
@@ -6,7 +6,6 @@ module LiveCable
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
def receive(component, data)
|
|
9
|
-
check_csrf_token(data)
|
|
10
9
|
reset_changeset
|
|
11
10
|
|
|
12
11
|
return unless data['messages'].present?
|
|
@@ -72,20 +71,6 @@ module LiveCable
|
|
|
72
71
|
|
|
73
72
|
private
|
|
74
73
|
|
|
75
|
-
def check_csrf_token(data)
|
|
76
|
-
session = request.session
|
|
77
|
-
return unless session[:_csrf_token]
|
|
78
|
-
|
|
79
|
-
token = data['_csrf_token']
|
|
80
|
-
unless csrf_checker.valid?(session, token)
|
|
81
|
-
raise LiveCable::Error, 'Invalid CSRF token'
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def csrf_checker
|
|
86
|
-
@csrf_checker ||= LiveCable::CsrfChecker.new(request)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
74
|
def parse_params(data)
|
|
90
75
|
params = data['params'] || ''
|
|
91
76
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
module LiveCable
|
|
4
4
|
class Connection
|
|
5
5
|
include ComponentManagement
|
|
6
|
-
include ChannelManagement
|
|
7
6
|
include StateManagement
|
|
8
7
|
include Messaging
|
|
9
8
|
include Broadcasting
|
|
@@ -13,17 +12,12 @@ module LiveCable
|
|
|
13
12
|
|
|
14
13
|
def initialize(request)
|
|
15
14
|
@request = request
|
|
16
|
-
@session_id = SecureRandom.uuid
|
|
17
15
|
@containers = Hash.new { |hash, key| hash[key] = Container.new }
|
|
18
16
|
@components = {}
|
|
19
|
-
@channels = {}
|
|
20
17
|
end
|
|
21
18
|
|
|
22
19
|
private
|
|
23
20
|
|
|
24
|
-
# @return [String]
|
|
25
|
-
attr_reader :session_id
|
|
26
|
-
|
|
27
21
|
# @return [ActionDispatch::Request]
|
|
28
22
|
attr_reader :request
|
|
29
23
|
|
data/lib/live_cable/engine.rb
CHANGED
|
@@ -26,6 +26,12 @@ module LiveCable
|
|
|
26
26
|
ActionView::Template.register_template_handler(:'live.erb', Rendering::Handler)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
initializer 'live_cable.action_cable' do
|
|
30
|
+
ActiveSupport.on_load(:action_cable_connection) do
|
|
31
|
+
include LiveCable::ActionCableConnection
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
initializer 'live_cable.active_record' do
|
|
30
36
|
ActiveSupport.on_load :active_record do
|
|
31
37
|
include ModelObserver
|
|
@@ -3,19 +3,28 @@
|
|
|
3
3
|
module LiveCable
|
|
4
4
|
module Testing
|
|
5
5
|
# Stand-in for an ActionCable channel. Records streams started via
|
|
6
|
-
# stream_from so tests can trigger their callbacks with receive_stream
|
|
6
|
+
# stream_from so tests can trigger their callbacks with receive_stream,
|
|
7
|
+
# and payloads sent by components.
|
|
7
8
|
class TestChannel
|
|
8
9
|
# @return [Hash<String, Hash>] Stream name => { coder:, callback: }
|
|
9
10
|
attr_reader :streams
|
|
10
11
|
|
|
12
|
+
# @return [Array<Hash>] Payloads sent by components through this channel
|
|
13
|
+
attr_reader :transmissions
|
|
14
|
+
|
|
11
15
|
# @return [LiveCable::Testing::TestCableConnection]
|
|
12
16
|
attr_reader :connection
|
|
13
17
|
|
|
14
18
|
def initialize(identifiers = {})
|
|
15
19
|
@streams = {}
|
|
20
|
+
@transmissions = []
|
|
16
21
|
@connection = TestCableConnection.new(identifiers)
|
|
17
22
|
end
|
|
18
23
|
|
|
24
|
+
def broadcast(data)
|
|
25
|
+
@transmissions << data
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def stream_from(name, coder: nil, &block)
|
|
20
29
|
@streams[name] = { coder:, callback: block }
|
|
21
30
|
end
|
|
@@ -18,9 +18,6 @@ module LiveCable
|
|
|
18
18
|
super(component)
|
|
19
19
|
@connection = connection
|
|
20
20
|
@channel = channel
|
|
21
|
-
@broadcasts = []
|
|
22
|
-
|
|
23
|
-
capture_broadcasts(component)
|
|
24
21
|
end
|
|
25
22
|
|
|
26
23
|
# @return [LiveCable::Component] The underlying component instance
|
|
@@ -74,15 +71,15 @@ module LiveCable
|
|
|
74
71
|
# (e.g. :_refresh, :_ack, :_error, :_status)
|
|
75
72
|
# @return [Array<Hash>]
|
|
76
73
|
def broadcasts(key = nil)
|
|
77
|
-
return
|
|
74
|
+
return channel.transmissions.dup unless key
|
|
78
75
|
|
|
79
|
-
|
|
76
|
+
channel.transmissions.select { |broadcast| broadcast.key?(key) }
|
|
80
77
|
end
|
|
81
78
|
|
|
82
79
|
# Forget previously captured broadcasts. Useful after mounting, to
|
|
83
80
|
# assert on the effects of a single action.
|
|
84
81
|
def clear_broadcasts
|
|
85
|
-
|
|
82
|
+
channel.transmissions.clear
|
|
86
83
|
end
|
|
87
84
|
|
|
88
85
|
# All events the component has dispatched via dispatch_event, in
|
|
@@ -134,14 +131,6 @@ module LiveCable
|
|
|
134
131
|
def receive_message(message)
|
|
135
132
|
connection.receive(component, { 'messages' => [message] })
|
|
136
133
|
end
|
|
137
|
-
|
|
138
|
-
def capture_broadcasts(component)
|
|
139
|
-
captured = @broadcasts
|
|
140
|
-
|
|
141
|
-
component.define_singleton_method(:broadcast) do |data|
|
|
142
|
-
captured << data
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
134
|
end
|
|
146
135
|
end
|
|
147
136
|
end
|
data/lib/live_cable/testing.rb
CHANGED
|
@@ -68,12 +68,11 @@ module LiveCable
|
|
|
68
68
|
def build_test_connection(raise_errors:)
|
|
69
69
|
require 'action_dispatch/testing/test_request'
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
request = ActionDispatch::TestRequest.create('rack.session' => {})
|
|
71
|
+
request = ActionDispatch::TestRequest.create
|
|
73
72
|
connection = LiveCable::Connection.new(request)
|
|
74
73
|
|
|
75
74
|
if raise_errors
|
|
76
|
-
def connection.handle_error(_component, error)
|
|
75
|
+
def connection.handle_error(_component, error, **)
|
|
77
76
|
raise error
|
|
78
77
|
end
|
|
79
78
|
end
|
data/lib/live_cable/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: live_cable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Craig Blanchette
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- lib/generators/live_cable/component/templates/view.html.live.erb.tt
|
|
134
134
|
- lib/live.rb
|
|
135
135
|
- lib/live_cable.rb
|
|
136
|
+
- lib/live_cable/action_cable_connection.rb
|
|
136
137
|
- lib/live_cable/component.rb
|
|
137
138
|
- lib/live_cable/component/broadcasting.rb
|
|
138
139
|
- lib/live_cable/component/events.rb
|
|
@@ -145,13 +146,11 @@ files:
|
|
|
145
146
|
- lib/live_cable/configuration.rb
|
|
146
147
|
- lib/live_cable/connection.rb
|
|
147
148
|
- lib/live_cable/connection/broadcasting.rb
|
|
148
|
-
- lib/live_cable/connection/channel_management.rb
|
|
149
149
|
- lib/live_cable/connection/component_management.rb
|
|
150
150
|
- lib/live_cable/connection/error_handling.rb
|
|
151
151
|
- lib/live_cable/connection/messaging.rb
|
|
152
152
|
- lib/live_cable/connection/state_management.rb
|
|
153
153
|
- lib/live_cable/container.rb
|
|
154
|
-
- lib/live_cable/csrf_checker.rb
|
|
155
154
|
- lib/live_cable/delegation/array.rb
|
|
156
155
|
- lib/live_cable/delegation/hash.rb
|
|
157
156
|
- lib/live_cable/delegation/methods.rb
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LiveCable
|
|
4
|
-
class CsrfChecker
|
|
5
|
-
include ActiveSupport::Configurable
|
|
6
|
-
include ActionController::RequestForgeryProtection
|
|
7
|
-
|
|
8
|
-
def initialize(request)
|
|
9
|
-
@request = request
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def valid?(session, token)
|
|
13
|
-
valid_authenticity_token?(session, token)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
attr_reader :request
|
|
19
|
-
end
|
|
20
|
-
end
|