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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9328496395172b7689b680c9fc54f222f26da2cc2d5a1cd25683fbcbdbe3c2bf
4
- data.tar.gz: c824da0a8a7cc10f3ca84a2965d264d88b879944b108d57e37599b0865b07b9d
3
+ metadata.gz: 9eab70af0621e69a52c7eaccce2d76a400a8627223c6aa91525230d262179782
4
+ data.tar.gz: 1a6ca70d53e728fc4893607bc5de7ab12094bbdefb06d52cd2a83afefd653ac5
5
5
  SHA512:
6
- metadata.gz: ed7c3db367dea001f0d50cfd0d05dd5c7ba837800438924e944c7a92041dd0b252293682792b17bb8730934a659ba2839a1c6ad283469fdacc00bfa4771f4a79
7
- data.tar.gz: a97bb8a0096edb1a4daa016b59051aef62a1d93eb1e8635d4a5462b5b6444ae5bdf28ae997b141f0e3ff0dfafde71fdcb08d98d322921c4e527e8e2c3ed416b6
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
- To use LiveCable, you need to set up your `ApplicationCable::Connection` to initialize a `LiveCable::Connection`.
34
-
35
- Add this to your `app/channels/application_cable/connection.rb`:
36
-
37
- ```ruby
38
- module ApplicationCable
39
- class Connection < ActionCable::Connection::Base
40
- identified_by :live_connection
41
-
42
- def connect
43
- self.live_connection = LiveCable::Connection.new(self.request)
44
- end
45
- end
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, _csrf_token: this.#csrfToken }
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
- def subscribed
5
- instance = nil
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
- # Build live_id from component and id params
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
- instance = live_connection.get_component(live_id)
11
- rendered = instance.present?
14
+ @component = live_connection.get_component(live_id)
15
+ rendered = component.present?
12
16
 
13
- unless instance
14
- instance = LiveCable.instance_from_string(params[:component], params[:id])
15
- live_connection.add_component(instance)
16
- instance.defaults = params[:defaults]
17
- instance.apply_defaults
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
- instance.connect(self)
24
+ component.connect(self)
21
25
 
22
26
  if rendered
23
- instance.broadcast_subscribe
27
+ component.broadcast_subscribe
24
28
  else
25
- instance.broadcast_render
29
+ component.broadcast_render
26
30
  end
27
-
28
- @component = instance
29
31
  rescue StandardError => error
30
- live_connection.handle_error(instance, error) if instance
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
- def handle_error(component, error)
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
- summary = "#{component.class.name} - #{error.class.name}: #{ERB::Util.html_escape(error.message)}"
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
- html = <<~HTML
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
 
@@ -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
@@ -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
- # An empty session skips the CSRF check, like a session-less request
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiveCable
4
- VERSION = '0.2.1'
4
+ VERSION = '0.3.0'
5
5
  end
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.2.1
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