motion 0.1.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: 2c9776bff100e3017a158810f93d3c374d97e6df42b8d9ffee43bf96da624b5b
4
- data.tar.gz: a0764632d832cd96b58b3ffa5c28e9598d09136b0dbe6eadc66ab074bc18d5bd
3
+ metadata.gz: 7645381b6b2749a4a9ba90a15c93916636e1b9edfd7b3a6a1bf7d47429fedb3f
4
+ data.tar.gz: 46c929b8aa2d1de8c714eb8b8db1db8524aec892576a13ad1582b7fa858565f7
5
5
  SHA512:
6
- metadata.gz: b5a0955da6087872bd9ec9940b8b3a674bf6fd2782ccad6b72460255703ff810e06e3d62ea99484a6c95d6fb48883953bbdec1cb3224af2513006ba60d5f3c54
7
- data.tar.gz: a0360a0b1a0422b38f5bd4f9a46ba203ed9f1d3a1a6adf6073c89ff77152c0ae5cf212d097e6ad8b864f2bf220772511887db3c1692d7c4b7be3d3f54be01ec5
6
+ metadata.gz: b8c99a480f51e9d818f5d22de3efbf8923155943d1815188e19332c54b8f24b909bc371abc7282d3a9caac754cf1b0aee4bb3193dad75b7f1d86a9112ed13983
7
+ data.tar.gz: a12e9213ba0ae38cbc5d839ae1408a5aab064be055896300f19a2296ebcd74b49922b96636bc52bd7b7aef62be9868ea47437544938f20facaad45534744bc4e
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Motion
6
+ module Generators
7
+ class ComponentGenerator < Rails::Generators::NamedBase
8
+ desc "Creates a Motion-enabled component in your application."
9
+
10
+ argument :attributes, type: :array, default: [], banner: "attribute"
11
+
12
+ def generate_component
13
+ generate "component", class_name, *attributes.map(&:name)
14
+ end
15
+
16
+ def include_motion
17
+ inject_into_class component_path, "#{class_name}Component" do
18
+ " include Motion::Component\n\n"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def component_path
25
+ @component_path ||=
26
+ File.join("app/components", class_path, "#{file_name}_component.rb")
27
+ end
28
+
29
+ def file_name
30
+ @_file_name ||= super.sub(/_component\z/i, "")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -16,10 +16,17 @@ module Motion
16
16
  )
17
17
  end
18
18
 
19
- def copy_stimlus_controller
19
+ def copy_client_initializer
20
20
  template(
21
- "motion_controller.js",
22
- "app/javascript/controllers/motion_controller.js"
21
+ "motion.js",
22
+ "app/javascript/motion.js"
23
+ )
24
+ end
25
+
26
+ def add_client_to_application_pack
27
+ append_to_file(
28
+ "app/javascript/packs/application.js",
29
+ 'import "motion"'
23
30
  )
24
31
  end
25
32
  end
@@ -0,0 +1,37 @@
1
+ import { createClient } from '@unabridged/motion';
2
+ import consumer from './channels/consumer';
3
+
4
+ export default createClient({
5
+
6
+ // To avoid creating a second websocket, make sure to reuse the application's
7
+ // ActionCable consumer. If you are not otherwise using ActionCable, you can
8
+ // remove this line and the corresponding import.
9
+ consumer,
10
+
11
+ // Motion can log information about the lifecycle of components to the
12
+ // browser's console. It is recommended to turn this feature off outside of
13
+ // development.
14
+ logging: process.env["RAILS_ENV"] === "development",
15
+
16
+ // This function will be called for every motion, and the return value will be
17
+ // made available at `Motion::Event#extra_data`:
18
+ //
19
+ // getExtraDataForEvent(event) {},
20
+
21
+ // By default, the Motion client automatically disconnects all components when
22
+ // it detects the browser navigating away to a new page. This is done to
23
+ // prevent flashes of new content in components with broadcasts because of
24
+ // some action being taken by the controller that the user is navigating to
25
+ // (like submitting a form). If you do not want or need this functionally, you
26
+ // can turn it off:
27
+ //
28
+ // shutdownBeforeUnload: false,
29
+
30
+ // The data attributes used by Motion can be customized, but these values must
31
+ // also be updated in the Ruby initializer:
32
+ //
33
+ // keyAttribute: "data-motion-key",
34
+ // stateAttribute: "data-motion-state",
35
+ // motionAttribute: "data-motion",
36
+
37
+ });
@@ -1,22 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO: Explain all the options.
4
3
  Motion.configure do |config|
5
- # config.secret = Rails.application.key_generator.generate_key "motion:secret"
4
+ # Motion needs to be able to uniquely identify the version of the running
5
+ # version of your application. By default, the commit hash from git is used,
6
+ # but depending on your deployment, this may not be available in production.
7
+ #
8
+ # Motion automatically calculates your revision by hashing the contents of
9
+ # files in `revision_paths` The defaults revision paths are:
10
+ # rails paths, bin, and Gemfile.lock.
11
+ #
12
+ # To change or add to your revision paths, uncomment this line:
13
+ #
14
+ # config.revision_paths += w(additional_path another_path)
15
+ #
16
+ # If you prefer to use git or an environmental variable for the revision
17
+ # in production, define the revision directly below.
18
+ #
19
+ # config.revision =
20
+ # ENV.fetch("MY_DEPLOYMENT_NUMBER") { `git rev-parse HEAD`.chomp }
21
+ #
22
+ # Using a value that does not change on every deployment will likely lead to
23
+ # confusing errors if components are connected during a deployment.
6
24
 
7
- # config.revision = `git rev-parse HEAD`.chomp
25
+ # This proc will be invoked by Motion in order to create a renderer for each
26
+ # websocket connection. By default, your `ApplicationController` will be used
27
+ # and the session/cookies **as they were when the websocket was first open**
28
+ # will be available:
29
+ #
30
+ # config.renderer_for_connection_proc = ->(websocket_connection) do
31
+ # ApplicationController.renderer.new(
32
+ # websocket_connection.env.slice(
33
+ # Rack::HTTP_COOKIE, # Cookies
34
+ # Rack::RACK_SESSION, # Session
35
+ # 'warden' # Warden (needed for `current_user` in Devise)
36
+ # )
37
+ # )
38
+ # end
8
39
 
9
- # config.renderer_for_connection_proc = ->(websocket_connection) do
10
- # ApplicationController.renderer.new(
11
- # websocket_connection.env.slice(
12
- # Rack::HTTP_COOKIE,
13
- # Rack::RACK_SESSION,
14
- # )
15
- # )
16
- # end
40
+ # This proc will be invoked by Motion when an unhandled error occurs. By
41
+ # default, an error is logged to the application's default logger but no
42
+ # additional action is taken. If you are using an error tracking tool like
43
+ # Bugsnag, Sentry, Honeybadger, or Rollbar, you can provide a proc which
44
+ # notifies that as well:
45
+ #
46
+ # config.error_notification_proc = ->(error, message) do
47
+ # Bugsnag.notify(error) do |report|
48
+ # report.add_tab(:motion, {
49
+ # message: message
50
+ # })
51
+ # end
52
+ # end
17
53
 
18
- # config.stimulus_controller_identifier = "motion"
19
- # config.key_attribute = "data-motion-key"
20
- # config.state_attribute = "data-motion-state"
21
- # config.motion_attribute = "data-motion"
54
+ # The data attributes used by Motion can be customized, but these values must
55
+ # also be updated in the JavaScript client configuration:
56
+ #
57
+ # config.key_attribute = "data-motion-key"
58
+ # config.state_attribute = "data-motion-state"
59
+ # config.motion_attribute = "data-motion"
60
+ #
22
61
  end
@@ -14,6 +14,7 @@ module Motion
14
14
  autoload :LogHelper, "motion/log_helper"
15
15
  autoload :MarkupTransformer, "motion/markup_transformer"
16
16
  autoload :Railtie, "motion/railtie"
17
+ autoload :RevisionCalculator, "motion/revision_calculator"
17
18
  autoload :Serializer, "motion/serializer"
18
19
  autoload :TestHelpers, "motion/test_helpers"
19
20
 
@@ -41,6 +42,10 @@ module Motion
41
42
  config.renderer_for_connection_proc.call(websocket_connection)
42
43
  end
43
44
 
45
+ def self.notify_error(error, message)
46
+ config.error_notification_proc&.call(error, message)
47
+ end
48
+
44
49
  # This method only exists for testing. Changing configuration while Motion is
45
50
  # in use is not supported. It is only safe to call this method when no
46
51
  # components are currently mounted.
@@ -4,10 +4,16 @@ require "motion"
4
4
 
5
5
  module Motion
6
6
  module ActionCableExtentions
7
+ autoload :DeclarativeNotifications,
8
+ "motion/action_cable_extentions/declarative_notifications"
9
+
7
10
  autoload :DeclarativeStreams,
8
11
  "motion/action_cable_extentions/declarative_streams"
9
12
 
10
13
  autoload :LogSuppression,
11
14
  "motion/action_cable_extentions/log_suppression"
15
+
16
+ autoload :Synchronization,
17
+ "motion/action_cable_extentions/synchronization"
12
18
  end
13
19
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module ActionCableExtentions
7
+ # Provides a `periodically_notify(broadcasts, to:)` API that can be used to
8
+ # declaratively specify when a handler should be called.
9
+ module DeclarativeNotifications
10
+ include Synchronization
11
+
12
+ def initialize(*)
13
+ super
14
+
15
+ # The current set of declarative notifications
16
+ @_declarative_notifications = {}
17
+
18
+ # The active timers for the declarative notifications
19
+ @_declarative_notifications_timers = {}
20
+
21
+ # The method we are routing declarative notifications to
22
+ @_declarative_notifications_target = nil
23
+ end
24
+
25
+ def declarative_notifications
26
+ @_declarative_notifications
27
+ end
28
+
29
+ def periodically_notify(notifications, via:)
30
+ (@_declarative_notifications.to_a - notifications.to_a)
31
+ .each do |notification, _interval|
32
+ _shutdown_declarative_notifcation_timer(notification)
33
+ end
34
+
35
+ (notifications.to_a - @_declarative_notifications.to_a)
36
+ .each do |notification, interval|
37
+ _setup_declarative_notifcation_timer(notification, interval)
38
+ end
39
+
40
+ @_declarative_notifications = notifications
41
+ @_declarative_notifications_target = via
42
+ end
43
+
44
+ private
45
+
46
+ def stop_periodic_timers
47
+ super
48
+
49
+ @_declarative_notifications.clear
50
+ @_declarative_notifications_timers.clear
51
+ @_declarative_notifications_target = nil
52
+ end
53
+
54
+ # The only public interface in ActionCable for defining periodic timers is
55
+ # exposed at the class level. Looking at the source though, it is easy to
56
+ # see that new timers can be setup with `start_periodic_timer`. To ensure
57
+ # that we do not leak any timers, it is important to store these instances
58
+ # in `active_periodic_timers` so that ActionCable cleans them up for us
59
+ # when the channel shuts down. Also, periodic timers are not supported by
60
+ # the testing adapter, so we have to skip all of this in unit tests (it
61
+ # _will_ be covered in systems tests though).
62
+ #
63
+ # See `ActionCable::Channel::PeriodicTimers` for details.
64
+ def _setup_declarative_notifcation_timer(notification, interval)
65
+ return if _stubbed_connection? ||
66
+ @_declarative_notifications_timers.include?(notification)
67
+
68
+ callback = proc do
69
+ synchronize_entrypoint! do
70
+ _handle_declarative_notifcation(notification)
71
+ end
72
+ end
73
+
74
+ timer = start_periodic_timer(callback, every: interval)
75
+
76
+ @_declarative_notifications_timers[notification] = timer
77
+ active_periodic_timers << timer
78
+ end
79
+
80
+ def _stubbed_connection?
81
+ defined?(ActionCable::Channel::ConnectionStub) &&
82
+ connection.is_a?(ActionCable::Channel::ConnectionStub)
83
+ end
84
+
85
+ def _shutdown_declarative_notifcation_timer(notification, *)
86
+ timer = @_declarative_notifications_timers.delete(notification)
87
+ return unless timer
88
+
89
+ timer.shutdown
90
+ active_periodic_timers.delete(timer)
91
+ end
92
+
93
+ def _handle_declarative_notifcation(notification)
94
+ return unless @_declarative_notifications_target &&
95
+ @_declarative_notifications.include?(notification)
96
+
97
+ send(@_declarative_notifications_target, notification)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -6,19 +6,13 @@ module Motion
6
6
  module ActionCableExtentions
7
7
  # Provides a `streaming_from(broadcasts, to:)` API that can be used to
8
8
  # declaratively specify what `broadcasts` the channel is interested in
9
- # receiving and `to` what method they should be routed. Additionally,
10
- # this module extends the "at most one executor at a time" property that
11
- # naturally comes with actions to the streams that it sets up as well.
9
+ # receiving and `to` what method they should be routed.
12
10
  module DeclarativeStreams
11
+ include Synchronization
12
+
13
13
  def initialize(*)
14
14
  super
15
15
 
16
- # Allowing actions to be bound to streams (as this module provides)
17
- # introduces the possibiliy of multiple threads accessing user code at
18
- # the same time. Protect user code with a Monitor so we only have to
19
- # worry about that here.
20
- @_declarative_stream_monitor = Monitor.new
21
-
22
16
  # Streams that we are currently interested in
23
17
  @_declarative_streams = Set.new
24
18
 
@@ -30,19 +24,6 @@ module Motion
30
24
  @_declarative_stream_proxies = Set.new
31
25
  end
32
26
 
33
- # Synchronize all ActionCable entry points (after initialization).
34
- def subscribe_to_channel(*)
35
- @_declarative_stream_monitor.synchronize { super }
36
- end
37
-
38
- def unsubscribe_from_channel(*)
39
- @_declarative_stream_monitor.synchronize { super }
40
- end
41
-
42
- def perform_action(*)
43
- @_declarative_stream_monitor.synchronize { super }
44
- end
45
-
46
27
  # Clean up declarative streams when all streams are stopped.
47
28
  def stop_all_streams
48
29
  super
@@ -70,34 +51,20 @@ module Motion
70
51
  def _ensure_declarative_stream_proxy(broadcast)
71
52
  return unless @_declarative_stream_proxies.add?(broadcast)
72
53
 
73
- # TODO: Something about this doesn't deal with the coder correctly.
74
- stream_from(broadcast) do |message|
75
- _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
76
- rescue Exception => exception # rubocop:disable Lint/RescueException
77
- # It is very, very important that we do not allow an exception to
78
- # escape here as the internals of ActionCable will stop processing
79
- # the broadcast.
80
-
81
- _handle_exception_in_declarative_stream(broadcast, exception)
54
+ # TODO: I feel like the fact that we have to specify the coder here is
55
+ # a bug in ActionCable. It should be the default for this karg.
56
+ stream_from(broadcast, coder: ActiveSupport::JSON) do |message|
57
+ synchronize_entrypoint! do
58
+ _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
59
+ end
82
60
  end
83
61
  end
84
62
 
85
63
  def _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
86
- @_declarative_stream_monitor.synchronize do
87
- return unless @_declarative_stream_target &&
88
- @_declarative_streams.include?(broadcast)
89
-
90
- send(@_declarative_stream_target, broadcast, message)
91
- end
92
- end
64
+ return unless @_declarative_stream_target &&
65
+ @_declarative_streams.include?(broadcast)
93
66
 
94
- def _handle_exception_in_declarative_stream(broadcast, exception)
95
- logger.error(
96
- "There was an exception while handling a broadcast to #{broadcast}" \
97
- "on #{self.class}:\n" \
98
- " #{exception.class}: #{exception.message}\n" \
99
- "#{exception.backtrace.map { |line| " #{line}" }.join("\n")}"
100
- )
67
+ send(@_declarative_stream_target, broadcast, message)
101
68
  end
102
69
  end
103
70
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module ActionCableExtentions
7
+ module Synchronization
8
+ def initialize(*)
9
+ super
10
+
11
+ @_monitor = Monitor.new
12
+ end
13
+
14
+ # Additional entrypoints added by other modules should wrap any entry
15
+ # points that they add with this.
16
+ def synchronize_entrypoint!(&block)
17
+ @_monitor.synchronize(&block)
18
+ end
19
+
20
+ # Synchronize all standard ActionCable entry points.
21
+ def subscribe_to_channel(*)
22
+ synchronize_entrypoint! { super }
23
+ end
24
+
25
+ def unsubscribe_from_channel(*)
26
+ synchronize_entrypoint! { super }
27
+ end
28
+
29
+ def perform_action(*)
30
+ synchronize_entrypoint! { super }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -6,6 +6,7 @@ require "motion"
6
6
 
7
7
  module Motion
8
8
  class Channel < ActionCable::Channel::Base
9
+ include ActionCableExtentions::DeclarativeNotifications
9
10
  include ActionCableExtentions::DeclarativeStreams
10
11
  include ActionCableExtentions::LogSuppression
11
12
 
@@ -24,9 +25,7 @@ module Motion
24
25
  def subscribed
25
26
  state, client_version = params.values_at("state", "version")
26
27
 
27
- # TODO: This is too restrictive. Introduce a protocol version and support
28
- # older versions of the client that have a compatible protocol.
29
- unless Motion::VERSION == client_version
28
+ if Gem::Version.new(Motion::VERSION) < Gem::Version.new(client_version)
30
29
  raise IncompatibleClientError.new(Motion::VERSION, client_version)
31
30
  end
32
31
 
@@ -58,10 +57,19 @@ module Motion
58
57
  synchronize
59
58
  end
60
59
 
60
+ def process_periodic_timer(timer)
61
+ component_connection.process_periodic_timer(timer)
62
+ synchronize
63
+ end
64
+
61
65
  private
62
66
 
63
67
  def synchronize
64
- streaming_from(component_connection.broadcasts, to: :process_broadcast)
68
+ streaming_from component_connection.broadcasts,
69
+ to: :process_broadcast
70
+
71
+ periodically_notify component_connection.periodic_timers,
72
+ via: :process_periodic_timer
65
73
 
66
74
  component_connection.if_render_required do |component|
67
75
  transmit(renderer.render(component))