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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class Configuration
7
+ class << self
8
+ attr_reader :options
9
+
10
+ def default
11
+ @default ||= new
12
+ end
13
+
14
+ private
15
+
16
+ attr_writer :options
17
+
18
+ def option(option, &default)
19
+ define_option_reader(option, &default)
20
+ define_option_writer(option)
21
+
22
+ self.options = [*options, option].freeze
23
+ end
24
+
25
+ def define_option_reader(option, &default)
26
+ define_method(option) do
27
+ if instance_variable_defined?(:"@#{option}")
28
+ instance_variable_get(:"@#{option}")
29
+ else
30
+ instance_variable_set(:"@#{option}", instance_exec(&default))
31
+ end
32
+ end
33
+ end
34
+
35
+ def define_option_writer(option)
36
+ define_method(:"#{option}=") do |value|
37
+ raise AlreadyConfiguredError if @finalized
38
+
39
+ instance_variable_set(:"@#{option}", value)
40
+ end
41
+ end
42
+ end
43
+
44
+ def initialize
45
+ yield self if block_given?
46
+
47
+ # Ensure a value is selected for all options
48
+ self.class.options.each(&method(:public_send))
49
+
50
+ # Prevent further changes
51
+ @finalized = true
52
+ end
53
+
54
+ # //////////////////////////////////////////////////////////////////////////
55
+
56
+ option :secret do
57
+ require "rails"
58
+
59
+ Rails.application.key_generator.generate_key("motion:secret")
60
+ end
61
+
62
+ option :revision do
63
+ warn <<~MSG # TODO: Better message (Focus on "How do I fix this?")
64
+ Motion is automatically inferring the application's revision from git.
65
+ Depending on your deployment, this may not work for you in production.
66
+ If it does, add "config.revision = `git rev-parse HEAD`.chomp" to your
67
+ Motion initializer. If it does not, do something else (probably read an
68
+ env var or something).
69
+ MSG
70
+
71
+ `git rev-parse HEAD`.chomp
72
+ end
73
+
74
+ option :renderer_for_connection_proc do
75
+ ->(websocket_connection) do
76
+ require "rack"
77
+ require "action_controller"
78
+
79
+ # Make a special effort to use the host application's base controller
80
+ # in case the CSRF protection has been customized, but don't couple to
81
+ # a particular constant from the outer application.
82
+ controller =
83
+ if defined?(ApplicationController)
84
+ ApplicationController
85
+ else
86
+ ActionController::Base
87
+ end
88
+
89
+ controller.renderer.new(
90
+ websocket_connection.env.slice(
91
+ Rack::HTTP_COOKIE,
92
+ Rack::RACK_SESSION
93
+ )
94
+ )
95
+ end
96
+ end
97
+
98
+ option(:stimulus_controller_identifier) { "motion" }
99
+ option(:key_attribute) { "data-motion-key" }
100
+ option(:state_attribute) { "data-motion-state" }
101
+
102
+ # This is included for completeness. It is not currently used internally by
103
+ # Motion, but it might be required for building view helpers in the future.
104
+ option(:motion_attribute) { "data-motion" }
105
+ end
106
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class Element
7
+ def self.from_raw(raw)
8
+ new(raw) if raw
9
+ end
10
+
11
+ attr_reader :raw
12
+
13
+ def initialize(raw)
14
+ @raw = raw.freeze
15
+ end
16
+
17
+ def tag_name
18
+ raw["tagName"]
19
+ end
20
+
21
+ def value
22
+ raw["value"]
23
+ end
24
+
25
+ def attributes
26
+ raw.fetch("attributes", {})
27
+ end
28
+
29
+ def [](key)
30
+ key = key.to_s
31
+
32
+ attributes[key] || attributes[key.tr("_", "-")]
33
+ end
34
+
35
+ def id
36
+ self[:id]
37
+ end
38
+
39
+ class DataAttributes
40
+ attr_reader :element
41
+
42
+ def initialize(element)
43
+ @element = element
44
+ end
45
+
46
+ def [](data)
47
+ element["data-#{data}"]
48
+ end
49
+ end
50
+
51
+ private_constant :DataAttributes
52
+
53
+ def data
54
+ return @data if defined?(@data)
55
+
56
+ @data = DataAttributes.new(self)
57
+ end
58
+
59
+ def form_data
60
+ return @form_data if defined?(@form_data)
61
+
62
+ @form_data =
63
+ ActionController::Parameters.new(
64
+ Rack::Utils.parse_nested_query(
65
+ raw.fetch("formData", "")
66
+ )
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class Error < StandardError; end
7
+
8
+ class ComponentError < Error
9
+ attr_reader :component
10
+
11
+ def initialize(component, message = nil)
12
+ super(message)
13
+ @component = component
14
+ end
15
+ end
16
+
17
+ class ComponentRenderingError < ComponentError; end
18
+
19
+ class MotionNotMapped < ComponentError
20
+ attr_reader :motion
21
+
22
+ def initialize(component, motion)
23
+ super(component, <<~MSG)
24
+ No component motion handler mapped for motion '#{motion}' in component #{component.class}.
25
+
26
+ Fix: Add the following to #{component.class}:
27
+
28
+ map_motion :#{motion}
29
+ MSG
30
+
31
+ @motion = motion
32
+ end
33
+ end
34
+
35
+ class BlockNotAllowedError < ComponentRenderingError
36
+ def initialize(component)
37
+ super(component, <<~MSG)
38
+ Motion does not support rendering with a block.
39
+
40
+ Fix: Use a plain component and wrap with a motion component.
41
+ MSG
42
+ end
43
+ end
44
+
45
+ class MultipleRootsError < ComponentRenderingError
46
+ def initialize(component)
47
+ super(component, <<~MSG)
48
+ The template for #{component.class} can only have one root element.
49
+
50
+ Fix: Wrap all elements in a single element, such as <div> or <section>.
51
+ MSG
52
+ end
53
+ end
54
+
55
+ class InvalidComponentStateError < ComponentError; end
56
+
57
+ class UnrepresentableStateError < InvalidComponentStateError
58
+ def initialize(component, cause)
59
+ super(component, <<~MSG)
60
+ Some state prevented #{component.class} from being serialized into a
61
+ string. Motion components must be serializable using Marshal.dump. Many
62
+ types of objects are not serializable including procs, references to
63
+ anonymous classes, and more. See the documentation for Marshal.dump for
64
+ more information.
65
+
66
+ Fix: Ensure that any exotic state variables in #{component.class} are
67
+ removed or replaced.
68
+
69
+ The specific (but probably useless) error from Marshal was: #{cause}
70
+ MSG
71
+ end
72
+ end
73
+
74
+ class SerializedComponentError < Error; end
75
+
76
+ class InvalidSerializedStateError < SerializedComponentError
77
+ def initialize
78
+ super(<<~MSG)
79
+ The serialized state of your component is not valid.
80
+
81
+ Fix: Ensure that you have not tampered with the DOM.
82
+ MSG
83
+ end
84
+ end
85
+
86
+ class IncorrectRevisionError < SerializedComponentError
87
+ attr_reader :expected_revision,
88
+ :actual_revision
89
+
90
+ def initialize(expected_revision, actual_revision)
91
+ super(<<~MSG)
92
+ Cannot mount a component from another version of the application.
93
+
94
+ Expected revision `#{expected_revision}`;
95
+ Got `#{actual_revision}`
96
+
97
+ Read more: https://github.com/unabridged/motion/wiki/IncorrectRevisionError
98
+
99
+ Fix:
100
+ * Avoid tampering with Motion DOM elements and data attributes (e.g. data-motion-state).
101
+ * In production, enforce a page refresh for pages with Motion components on deploy.
102
+ MSG
103
+
104
+ @expected_revision = expected_revision
105
+ @actual_revision = actual_revision
106
+ end
107
+ end
108
+
109
+ class AlreadyConfiguredError < Error
110
+ def initialize
111
+ super(<<~MSG)
112
+ Motion is already configured.
113
+
114
+ Fix: Move all Motion config to config/initializers/motion.rb.
115
+ MSG
116
+ end
117
+ end
118
+
119
+ class IncompatibleClientError < Error
120
+ attr_reader :expected_version,
121
+ :actual_version
122
+
123
+ def initialize(expected_version, actual_version)
124
+ super(<<~MSG)
125
+ Expected client version #{expected_version}, but got #{actual_version}.
126
+
127
+ Fix: Run `bin/yarn add @unabridged/motion@#{expected_version}`
128
+ MSG
129
+ end
130
+ end
131
+
132
+ class BadSecretError < Error
133
+ attr_reader :minimum_bytes
134
+
135
+ def initialize(minimum_bytes)
136
+ super(<<~MSG)
137
+ The secret that you provided is not long enough. It must have at least
138
+ #{minimum_bytes} bytes.
139
+ MSG
140
+ end
141
+ end
142
+
143
+ class BadRevisionError < Error
144
+ def initialize
145
+ super("The revision cannot contain a NULL byte")
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class Event
7
+ def self.from_raw(raw)
8
+ new(raw) if raw
9
+ end
10
+
11
+ attr_reader :raw
12
+
13
+ def initialize(raw)
14
+ @raw = raw.freeze
15
+ end
16
+
17
+ def type
18
+ raw["type"]
19
+ end
20
+
21
+ alias name type
22
+
23
+ def details
24
+ raw.fetch("details", {})
25
+ end
26
+
27
+ def extra_data
28
+ raw["extraData"]
29
+ end
30
+
31
+ def target
32
+ return @target if defined?(@target)
33
+
34
+ @target = Motion::Element.from_raw(raw["target"])
35
+ end
36
+
37
+ def form_data
38
+ target&.form_data
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_support/core_ext/string/indent"
5
+
6
+ require "motion"
7
+
8
+ module Motion
9
+ class LogHelper
10
+ BACKTRACE_FRAMES = 5
11
+ DEFAULT_TAG = "Motion"
12
+
13
+ def self.for_channel(channel, logger: channel.connection.logger)
14
+ new(logger: logger, tag: DEFAULT_TAG)
15
+ end
16
+
17
+ def self.for_component(component, logger: nil)
18
+ new(logger: logger, tag: "#{component.class}:#{component.object_id}")
19
+ end
20
+
21
+ attr_reader :logger, :tag
22
+
23
+ def initialize(logger: nil, tag: nil)
24
+ @logger = logger || Rails.logger
25
+ @tag = tag || DEFAULT_TAG
26
+ end
27
+
28
+ def error(message, error: nil)
29
+ error_info = error ? ":\n#{indent(format_exception(error))}" : ""
30
+
31
+ logger.error("[#{tag}] #{message}#{error_info}")
32
+ end
33
+
34
+ def info(message)
35
+ logger.info("[#{tag}] #{message}")
36
+ end
37
+
38
+ def timing(message)
39
+ start_time = Time.now
40
+ result = yield
41
+ end_time = Time.now
42
+
43
+ info("#{message} (in #{format_duration(end_time - start_time)})")
44
+
45
+ result
46
+ end
47
+
48
+ def for_component(component)
49
+ self.class.for_component(component, logger: logger)
50
+ end
51
+
52
+ private
53
+
54
+ def format_exception(exception)
55
+ frames = exception.backtrace.first(BACKTRACE_FRAMES).join("\n")
56
+
57
+ "#{exception.class}: #{exception}\n#{indent(frames)}"
58
+ end
59
+
60
+ def format_duration(duration)
61
+ duration_ms = duration * 1000
62
+
63
+ if duration_ms < 0.1
64
+ "less than 0.1ms"
65
+ else
66
+ "#{duration_ms.round(1)}ms"
67
+ end
68
+ end
69
+
70
+ def indent(string)
71
+ string.indent(1, "\t")
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ require "motion"
6
+
7
+ module Motion
8
+ class MarkupTransformer
9
+ STIMULUS_CONTROLLER_ATTRIBUTE = "data-controller"
10
+
11
+ attr_reader :serializer,
12
+ :stimulus_controller_identifier,
13
+ :key_attribute,
14
+ :state_attribute
15
+
16
+ def initialize(
17
+ serializer: Motion.serializer,
18
+ stimulus_controller_identifier:
19
+ Motion.config.stimulus_controller_identifier,
20
+ key_attribute: Motion.config.key_attribute,
21
+ state_attribute: Motion.config.state_attribute
22
+ )
23
+ @serializer = serializer
24
+ @stimulus_controller_identifier = stimulus_controller_identifier
25
+ @key_attribute = key_attribute
26
+ @state_attribute = state_attribute
27
+ end
28
+
29
+ def add_state_to_html(component, html)
30
+ key, state = serializer.serialize(component)
31
+
32
+ transform_root(component, html) do |root|
33
+ root[STIMULUS_CONTROLLER_ATTRIBUTE] =
34
+ values(
35
+ stimulus_controller_identifier,
36
+ root[STIMULUS_CONTROLLER_ATTRIBUTE]
37
+ )
38
+
39
+ root[key_attribute] = key
40
+ root[state_attribute] = state
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def transform_root(component, html)
47
+ fragment = Nokogiri::HTML::DocumentFragment.parse(html)
48
+ root, *unexpected_others = fragment.children
49
+
50
+ raise MultipleRootsError, component if unexpected_others.any?(&:present?)
51
+
52
+ yield root
53
+
54
+ fragment.to_html.html_safe
55
+ end
56
+
57
+ def values(*values, delimiter: " ")
58
+ values
59
+ .compact
60
+ .flat_map { |value| value.split(delimiter) }
61
+ .uniq
62
+ .join(delimiter)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class MyRailtie < Rails::Railtie
7
+ generators do
8
+ require "generators/motion/install_generator"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support/message_encryptor"
5
+
6
+ require "motion"
7
+
8
+ module Motion
9
+ class Serializer
10
+ HASH_PEPPER = "Motion"
11
+ private_constant :HASH_PEPPER
12
+
13
+ NULL_BYTE = "\0"
14
+
15
+ attr_reader :secret, :revision
16
+
17
+ def self.minimum_secret_byte_length
18
+ ActiveSupport::MessageEncryptor.key_len
19
+ end
20
+
21
+ def initialize(
22
+ secret: Motion.config.secret,
23
+ revision: Motion.config.revision
24
+ )
25
+ unless secret.each_byte.count >= self.class.minimum_secret_byte_length
26
+ raise BadSecretError.new(self.class.minimum_secret_byte_length)
27
+ end
28
+
29
+ raise BadRevisionError if revision.include?(NULL_BYTE)
30
+
31
+ @secret = secret
32
+ @revision = revision
33
+ end
34
+
35
+ def serialize(component)
36
+ state = dump(component)
37
+ state_with_revision = "#{revision}#{NULL_BYTE}#{state}"
38
+
39
+ [
40
+ salted_digest(state_with_revision),
41
+ encrypt_and_sign(state_with_revision)
42
+ ]
43
+ end
44
+
45
+ def deserialize(serialized_component)
46
+ state_with_revision = decrypt_and_verify(serialized_component)
47
+ serialized_revision, state = state_with_revision.split(NULL_BYTE, 2)
48
+ component = load(state)
49
+
50
+ if revision == serialized_revision
51
+ component
52
+ else
53
+ component.class.upgrade_from(serialized_revision, component)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def dump(component)
60
+ Marshal.dump(component)
61
+ rescue TypeError => e
62
+ raise UnrepresentableStateError.new(component, e.message)
63
+ end
64
+
65
+ def load(state)
66
+ Marshal.load(state)
67
+ end
68
+
69
+ def encrypt_and_sign(cleartext)
70
+ encryptor.encrypt_and_sign(cleartext)
71
+ end
72
+
73
+ def decrypt_and_verify(cypertext)
74
+ encryptor.decrypt_and_verify(cypertext)
75
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage,
76
+ ActiveSupport::MessageVerifier::InvalidSignature
77
+ raise InvalidSerializedStateError
78
+ end
79
+
80
+ def salted_digest(input)
81
+ Digest::SHA256.base64digest(hash_salt + input)
82
+ end
83
+
84
+ def encryptor
85
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(derive_encryptor_key)
86
+ end
87
+
88
+ def hash_salt
89
+ @hash_salt ||= derive_hash_salt
90
+ end
91
+
92
+ def derive_encryptor_key
93
+ secret.byteslice(0, self.class.minimum_secret_byte_length)
94
+ end
95
+
96
+ def derive_hash_salt
97
+ Digest::SHA256.digest(HASH_PEPPER + secret)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ module TestHelpers
7
+ def assert_motion(component, motion_name)
8
+ assert motion?(component, motion_name)
9
+ end
10
+
11
+ def refute_motion(component, motion_name)
12
+ refute motion?(component, motion_name)
13
+ end
14
+
15
+ def motion?(component, motion_name)
16
+ component.motions.include?(motion_name.to_s)
17
+ end
18
+
19
+ def run_motion(component, motion_name)
20
+ if block_given?
21
+ c = component.dup
22
+ c.process_motion(motion_name.to_s)
23
+ yield c
24
+ else
25
+ component.process_motion(motion_name.to_s)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motion
4
+ VERSION = "0.1.1"
5
+ end
data/lib/motion.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion/version"
4
+ require "motion/errors"
5
+
6
+ module Motion
7
+ autoload :ActionCableExtentions, "motion/action_cable_extentions"
8
+ autoload :Channel, "motion/channel"
9
+ autoload :Component, "motion/component"
10
+ autoload :ComponentConnection, "motion/component_connection"
11
+ autoload :Configuration, "motion/configuration"
12
+ autoload :Element, "motion/element"
13
+ autoload :Event, "motion/event"
14
+ autoload :LogHelper, "motion/log_helper"
15
+ autoload :MarkupTransformer, "motion/markup_transformer"
16
+ autoload :Railtie, "motion/railtie"
17
+ autoload :Serializer, "motion/serializer"
18
+ autoload :TestHelpers, "motion/test_helpers"
19
+
20
+ def self.configure(&block)
21
+ raise AlreadyConfiguredError if @config
22
+
23
+ @config = Configuration.new(&block)
24
+ end
25
+
26
+ def self.config
27
+ @config ||= Configuration.default
28
+ end
29
+
30
+ singleton_class.alias_method :configuration, :config
31
+
32
+ def self.serializer
33
+ @serializer ||= Serializer.new
34
+ end
35
+
36
+ def self.markup_transformer
37
+ @markup_transformer ||= MarkupTransformer.new
38
+ end
39
+
40
+ def self.build_renderer_for(websocket_connection)
41
+ config.renderer_for_connection_proc.call(websocket_connection)
42
+ end
43
+
44
+ # This method only exists for testing. Changing configuration while Motion is
45
+ # in use is not supported. It is only safe to call this method when no
46
+ # components are currently mounted.
47
+ def self.reset_internal_state_for_testing!(new_configuration = nil)
48
+ @config = new_configuration
49
+ @serializer = nil
50
+ @markup_transformer = nil
51
+ end
52
+ end
53
+
54
+ require "motion/railtie" if defined?(Rails)