motion 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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