motion 0.1.1

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