motion 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c9776bff100e3017a158810f93d3c374d97e6df42b8d9ffee43bf96da624b5b
4
+ data.tar.gz: a0764632d832cd96b58b3ffa5c28e9598d09136b0dbe6eadc66ab074bc18d5bd
5
+ SHA512:
6
+ metadata.gz: b5a0955da6087872bd9ec9940b8b3a674bf6fd2782ccad6b72460255703ff810e06e3d62ea99484a6c95d6fb48883953bbdec1cb3224af2513006ba60d5f3c54
7
+ data.tar.gz: a0360a0b1a0422b38f5bd4f9a46ba203ed9f1d3a1a6adf6073c89ff77152c0ae5cf212d097e6ad8b864f2bf220772511887db3c1692d7c4b7be3d3f54be01ec5
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Motion
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Installs Motion into your application."
11
+
12
+ def copy_initializer
13
+ template(
14
+ "motion.rb",
15
+ "config/initializers/motion.rb"
16
+ )
17
+ end
18
+
19
+ def copy_stimlus_controller
20
+ template(
21
+ "motion_controller.js",
22
+ "app/javascript/controllers/motion_controller.js"
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: Explain all the options.
4
+ Motion.configure do |config|
5
+ # config.secret = Rails.application.key_generator.generate_key "motion:secret"
6
+
7
+ # config.revision = `git rev-parse HEAD`.chomp
8
+
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
17
+
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"
22
+ end
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@unabridged/motion";
2
+ import consumer from "../channels/consumer";
3
+
4
+ // If you change the name of this controller (determined by the file name),
5
+ // make sure to update `Motion.config.stimulus_controller_identifier`.
6
+ export default class extends Controller {
7
+ // To avoid creating a second websocket, make sure to reuse the application's
8
+ // ActionCable consumer.
9
+ getConsumer() {
10
+ return consumer;
11
+ }
12
+
13
+ // It is possible to additionally customize the behavior of the client by
14
+ // overriding these properties and methods:
15
+
16
+ // getExtraDataForEvent(event) {} // `Motion::Event#extra_data`
17
+
18
+ // keyAttribute = "data-motion-key"; // `Motion.config.key_attribute`
19
+ // stateAttribute = "data-motion-state"; // `Motion.config.state_attribute`
20
+ // motionAttribute = "data-motion"; // `Motion.config.motion_attribute`
21
+
22
+ // beforeConnect() { /* by default, dispatches `motion:before-connect` */ }
23
+ // connected() { /* by default, dispatches `motion:connected` */ }
24
+ // connectFailed() { /* by default, dispatches `motion:connect-failed` */ }
25
+ // disconnected() { /* by default, dispatches `motion:disconnected` */ }
26
+ // beforeRender() { /* by default, dispatches `motion:before-render` */ }
27
+ // rendered() { /* by default, dispatches `motion:rendered` */ }
28
+ }
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module ActionCableExtentions
7
+ # Provides a `streaming_from(broadcasts, to:)` API that can be used to
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.
12
+ module DeclarativeStreams
13
+ def initialize(*)
14
+ super
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
+ # Streams that we are currently interested in
23
+ @_declarative_streams = Set.new
24
+
25
+ # The method we are currently routing those streams to
26
+ @_declarative_stream_target = nil
27
+
28
+ # Streams that we are setup to listen to. Sadly, there is no public API
29
+ # to stop streaming so this will only grow.
30
+ @_declarative_stream_proxies = Set.new
31
+ end
32
+
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
+ # Clean up declarative streams when all streams are stopped.
47
+ def stop_all_streams
48
+ super
49
+
50
+ @_declarative_streams.clear
51
+ @_declarative_stream_target = nil
52
+
53
+ @_declarative_stream_proxies.clear
54
+ end
55
+
56
+ # Declaratively routes provided broadcasts to the provided method.
57
+ def streaming_from(broadcasts, to:)
58
+ @_declarative_streams.replace(broadcasts)
59
+ @_declarative_stream_target = to
60
+
61
+ @_declarative_streams.each(&method(:_ensure_declarative_stream_proxy))
62
+ end
63
+
64
+ def declarative_stream_target
65
+ @_declarative_stream_target
66
+ end
67
+
68
+ private
69
+
70
+ def _ensure_declarative_stream_proxy(broadcast)
71
+ return unless @_declarative_stream_proxies.add?(broadcast)
72
+
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)
82
+ end
83
+ end
84
+
85
+ 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
93
+
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
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module ActionCableExtentions
7
+ # By default ActionCable logs a lot. This module suppresses the debugging
8
+ # information on a _per channel_ basis.
9
+ module LogSuppression
10
+ class Suppressor < SimpleDelegator
11
+ def info(*)
12
+ end
13
+
14
+ def debug(*)
15
+ end
16
+ end
17
+
18
+ private_constant :Suppressor
19
+
20
+ def initialize(*)
21
+ super
22
+
23
+ @_logger = Suppressor.new(logger)
24
+ end
25
+
26
+ def logger
27
+ return super unless defined?(@_logger)
28
+
29
+ @_logger
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module ActionCableExtentions
7
+ autoload :DeclarativeStreams,
8
+ "motion/action_cable_extentions/declarative_streams"
9
+
10
+ autoload :LogSuppression,
11
+ "motion/action_cable_extentions/log_suppression"
12
+ end
13
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+
5
+ require "motion"
6
+
7
+ module Motion
8
+ class Channel < ActionCable::Channel::Base
9
+ include ActionCableExtentions::DeclarativeStreams
10
+ include ActionCableExtentions::LogSuppression
11
+
12
+ ACTION_METHODS = Set.new(["process_motion"]).freeze
13
+ private_constant :ACTION_METHODS
14
+
15
+ # Don't use the ActionCable huertistic for deciding what actions can be
16
+ # called from JavaScript. Instead, hard-code the list so we can make other
17
+ # methods public without worrying about them being called from JavaScript.
18
+ def self.action_methods
19
+ ACTION_METHODS
20
+ end
21
+
22
+ attr_reader :component_connection
23
+
24
+ def subscribed
25
+ state, client_version = params.values_at("state", "version")
26
+
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
30
+ raise IncompatibleClientError.new(Motion::VERSION, client_version)
31
+ end
32
+
33
+ @component_connection =
34
+ ComponentConnection.from_state(state, log_helper: log_helper)
35
+
36
+ synchronize
37
+ rescue => error
38
+ reject
39
+
40
+ handle_error(error, "connecting a component")
41
+ end
42
+
43
+ def unsubscribed
44
+ component_connection&.close
45
+
46
+ @component_connection = nil
47
+ end
48
+
49
+ def process_motion(data)
50
+ motion, raw_event = data.values_at("name", "event")
51
+
52
+ component_connection.process_motion(motion, Event.from_raw(raw_event))
53
+ synchronize
54
+ end
55
+
56
+ def process_broadcast(broadcast, message)
57
+ component_connection.process_broadcast(broadcast, message)
58
+ synchronize
59
+ end
60
+
61
+ private
62
+
63
+ def synchronize
64
+ streaming_from(component_connection.broadcasts, to: :process_broadcast)
65
+
66
+ component_connection.if_render_required do |component|
67
+ transmit(renderer.render(component))
68
+ end
69
+ end
70
+
71
+ def handle_error(error, context)
72
+ log_helper.error("An error occurred while #{context}", error: error)
73
+ end
74
+
75
+ def log_helper
76
+ @log_helper ||= LogHelper.for_channel(self)
77
+ end
78
+
79
+ # Memoize the renderer on the connection so that it can be shared accross
80
+ # all components. `ActionController::Renderer` is already thread-safe and
81
+ # designed to be reused.
82
+ def renderer
83
+ connection.instance_eval do
84
+ @_motion_renderer ||= Motion.build_renderer_for(self)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/object/to_param"
6
+
7
+ require "motion"
8
+
9
+ module Motion
10
+ module Component
11
+ module Broadcasts
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ class_attribute :_broadcast_handlers,
16
+ instance_reader: false,
17
+ instance_writer: false,
18
+ instance_predicate: false,
19
+ default: {}.freeze
20
+ end
21
+
22
+ class_methods do
23
+ def broadcast_to(model, message)
24
+ ActionCable.server.broadcast(broadcasting_for(model), message)
25
+ end
26
+
27
+ def stream_from(broadcast, handler)
28
+ self._broadcast_handlers =
29
+ _broadcast_handlers.merge(broadcast.to_s => handler.to_sym).freeze
30
+ end
31
+
32
+ def stream_for(model, handler)
33
+ stream_from(broadcasting_for(model), handler)
34
+ end
35
+
36
+ def broadcasting_for(model)
37
+ serialize_broadcasting([name, model])
38
+ end
39
+
40
+ private
41
+
42
+ # Taken from ActionCable::Channel::Broadcasting
43
+ def serialize_broadcasting(object)
44
+ if object.is_a?(Array)
45
+ object.map { |m| serialize_broadcasting(m) }.join(":")
46
+ elsif object.respond_to?(:to_gid_param)
47
+ object.to_gid_param
48
+ else
49
+ object.to_param
50
+ end
51
+ end
52
+ end
53
+
54
+ def broadcasts
55
+ _broadcast_handlers.keys
56
+ end
57
+
58
+ def process_broadcast(broadcast, message)
59
+ return unless (handler = _broadcast_handlers[broadcast])
60
+
61
+ send(handler, message)
62
+ end
63
+
64
+ def broadcast_to(model, message)
65
+ self.class.broadcast_to(model, message)
66
+ end
67
+
68
+ def stream_from(broadcast, handler)
69
+ self._broadcast_handlers =
70
+ _broadcast_handlers.merge(broadcast.to_s => handler.to_sym).freeze
71
+ end
72
+
73
+ def stream_for(model, handler)
74
+ stream_from(self.class.broadcasting_for(model), handler)
75
+ end
76
+
77
+ private
78
+
79
+ attr_writer :_broadcast_handlers
80
+
81
+ def _broadcast_handlers
82
+ return @_broadcast_handlers if defined?(@_broadcast_handlers)
83
+
84
+ self.class._broadcast_handlers
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require "motion"
6
+
7
+ module Motion
8
+ module Component
9
+ module Lifecycle
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # TODO: "IncorrectRevisionError" doesn't make sense for this anymore.
14
+ # It should probably be something like "CannotUpgrade" and the error
15
+ # message should focus on how to handle deployments gracefully.
16
+ def upgrade_from(previous_revision, _instance)
17
+ raise IncorrectRevisionError.new(
18
+ Motion.config.revision,
19
+ previous_revision
20
+ )
21
+ end
22
+ end
23
+
24
+ def connected
25
+ end
26
+
27
+ def disconnected
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ require "motion"
7
+
8
+ module Motion
9
+ module Component
10
+ module Motions
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ class_attribute :_motion_handlers,
15
+ instance_reader: false,
16
+ instance_writer: false,
17
+ instance_predicate: false,
18
+ default: {}.freeze
19
+ end
20
+
21
+ class_methods do
22
+ def map_motion(motion, handler = motion)
23
+ self._motion_handlers =
24
+ _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
25
+ end
26
+ end
27
+
28
+ def motions
29
+ _motion_handlers.keys
30
+ end
31
+
32
+ def process_motion(motion, event = nil)
33
+ unless (handler = _motion_handlers[motion])
34
+ raise MotionNotMapped.new(self, motion)
35
+ end
36
+
37
+ if method(handler).arity.zero?
38
+ send(handler)
39
+ else
40
+ send(handler, event)
41
+ end
42
+ end
43
+
44
+ def map_motion(motion, handler = motion)
45
+ self._motion_handlers =
46
+ _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
47
+ end
48
+
49
+ private
50
+
51
+ attr_writer :_motion_handlers
52
+
53
+ def _motion_handlers
54
+ return @_motion_handlers if defined?(@_motion_handlers)
55
+
56
+ self.class._motion_handlers
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module Component
7
+ module Rendering
8
+ # Use the presence/absence of the ivar instead of true/false to avoid
9
+ # extra serialized state (Note that in this scheme, the presence of
10
+ # the ivar will never be serialized).
11
+ RERENDER_MARKER_IVAR = :@__awaiting_forced_rerender__
12
+ private_constant :RERENDER_MARKER_IVAR
13
+
14
+ def rerender!
15
+ instance_variable_set(RERENDER_MARKER_IVAR, true)
16
+ end
17
+
18
+ def awaiting_forced_rerender?
19
+ instance_variable_defined?(RERENDER_MARKER_IVAR)
20
+ end
21
+
22
+ # * This can be overwritten.
23
+ # * It will _not_ be sent to the client.
24
+ # * If it doesn't change every time the component's state changes,
25
+ # things may fall out of sync unless you also call `#rerender!`
26
+ def render_hash
27
+ # TODO: This implementation is trivially correct, but very wasteful.
28
+ #
29
+ # Is something with Ruby's built-in `hash` Good Enough(TM)?
30
+ #
31
+ # instance_variables
32
+ # .map { |ivar| instance_variable_get(ivar).hash }
33
+ # .reduce(0, &:^)
34
+
35
+ key, _state = Motion.serializer.serialize(self)
36
+ key
37
+ end
38
+
39
+ def render_in(view_context)
40
+ raise BlockNotAllowedError, self if block_given?
41
+ clear_awaiting_forced_rerender!
42
+
43
+ html = view_context.capture { without_new_instance_variables { super } }
44
+
45
+ Motion.markup_transformer.add_state_to_html(self, html)
46
+ end
47
+
48
+ private
49
+
50
+ def clear_awaiting_forced_rerender!
51
+ return unless awaiting_forced_rerender?
52
+
53
+ remove_instance_variable(RERENDER_MARKER_IVAR)
54
+ end
55
+
56
+ def without_new_instance_variables
57
+ existing_instance_variables = instance_variables
58
+
59
+ yield
60
+ ensure
61
+ (instance_variables - existing_instance_variables)
62
+ .each(&method(:remove_instance_variable))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require "motion"
6
+
7
+ require "motion/component/broadcasts"
8
+ require "motion/component/lifecycle"
9
+ require "motion/component/motions"
10
+ require "motion/component/rendering"
11
+
12
+ module Motion
13
+ module Component
14
+ extend ActiveSupport::Concern
15
+
16
+ include Broadcasts
17
+ include Lifecycle
18
+ include Motions
19
+ include Rendering
20
+ end
21
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class ComponentConnection
7
+ def self.from_state(
8
+ state,
9
+ serializer: Motion.serializer,
10
+ log_helper: LogHelper.new,
11
+ **kargs
12
+ )
13
+ component = serializer.deserialize(state)
14
+
15
+ new(component, log_helper: log_helper.for_component(component), **kargs)
16
+ end
17
+
18
+ attr_reader :component
19
+
20
+ def initialize(component, log_helper: LogHelper.for_component(component))
21
+ @component = component
22
+ @log_helper = log_helper
23
+
24
+ timing("Connected") do
25
+ @render_hash = component.render_hash
26
+
27
+ component.connected
28
+ end
29
+ end
30
+
31
+ def close
32
+ timing("Disconnected") do
33
+ component.disconnected
34
+ end
35
+
36
+ true
37
+ rescue => error
38
+ handle_error(error, "disconnecting the component")
39
+
40
+ false
41
+ end
42
+
43
+ def process_motion(motion, event = nil)
44
+ timing("Proccessed #{motion}") do
45
+ component.process_motion(motion, event)
46
+ end
47
+
48
+ true
49
+ rescue => error
50
+ handle_error(error, "processing #{motion}")
51
+
52
+ false
53
+ end
54
+
55
+ def process_broadcast(broadcast, message)
56
+ timing("Proccessed broadcast to #{broadcast}") do
57
+ component.process_broadcast broadcast, message
58
+ end
59
+
60
+ true
61
+ rescue => error
62
+ handle_error(error, "processing a broadcast to #{broadcast}")
63
+
64
+ false
65
+ end
66
+
67
+ def if_render_required(&block)
68
+ timing("Rendered") do
69
+ next_render_hash = component.render_hash
70
+
71
+ return if @render_hash == next_render_hash &&
72
+ !component.awaiting_forced_rerender?
73
+
74
+ yield(component)
75
+
76
+ @render_hash = next_render_hash
77
+ end
78
+ rescue => error
79
+ handle_error(error, "rendering the component")
80
+ end
81
+
82
+ def broadcasts
83
+ component.broadcasts
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :log_helper
89
+
90
+ def timing(context, &block)
91
+ log_helper.timing(context, &block)
92
+ end
93
+
94
+ def handle_error(error, context)
95
+ log_helper.error("An error occurred while #{context}", error: error)
96
+ end
97
+ end
98
+ end