live_cable 0.2.1 → 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 +62 -0
- data/README.md +13 -15
- data/app/assets/javascript/controllers/live_controller.js +3 -6
- data/app/channels/live_channel.rb +34 -16
- data/lib/live_cable/action_cable_connection.rb +19 -0
- data/lib/live_cable/connection/error_handling.rb +27 -10
- data/lib/live_cable/connection/messaging.rb +0 -15
- data/lib/live_cable/engine.rb +6 -0
- data/lib/live_cable/testing.rb +2 -3
- data/lib/live_cable/version.rb +1 -1
- metadata +2 -2
- 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,68 @@ 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
|
+
|
|
9
71
|
## 0.2.1 - 2026-08-13
|
|
10
72
|
|
|
11
73
|
### Removed
|
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
|
}
|
|
@@ -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
|
|
@@ -50,4 +59,13 @@ class LiveChannel < ActionCable::Channel::Base
|
|
|
50
59
|
|
|
51
60
|
# @return [LiveCable::Component, nil]
|
|
52
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
|
|
53
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,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
|
|
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
|
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
|
|
@@ -150,7 +151,6 @@ files:
|
|
|
150
151
|
- lib/live_cable/connection/messaging.rb
|
|
151
152
|
- lib/live_cable/connection/state_management.rb
|
|
152
153
|
- lib/live_cable/container.rb
|
|
153
|
-
- lib/live_cable/csrf_checker.rb
|
|
154
154
|
- lib/live_cable/delegation/array.rb
|
|
155
155
|
- lib/live_cable/delegation/hash.rb
|
|
156
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
|