turbo_reflex 0.0.10 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ import meta from '../meta'
2
+ import observable from './observable'
3
+ import { stateEvents as events } from '../events'
4
+
5
+ let oldState, state, changedState
6
+ let observer
7
+
8
+ function loadState () {
9
+ const json = atob(meta.element.dataset.state)
10
+ changedState = {}
11
+ oldState = state = observable(JSON.parse(json))
12
+ }
13
+
14
+ function initObserver () {
15
+ if (observer) observer.disconnect()
16
+ observer = new MutationObserver(loadState)
17
+ observer.observe(meta.element, {
18
+ attributes: true,
19
+ attributeFilter: ['data-state']
20
+ })
21
+ }
22
+
23
+ addEventListener('DOMContentLoaded', loadState)
24
+ addEventListener('DOMContentLoaded', initObserver)
25
+ addEventListener('turbo:load', initObserver)
26
+ addEventListener('turbo:frame-load', initObserver)
27
+
28
+ addEventListener(
29
+ events.beforeStateChange,
30
+ event => (oldState = JSON.parse(JSON.stringify(state)))
31
+ )
32
+
33
+ addEventListener(events.stateChange, event => {
34
+ changedState = {}
35
+ for (const [key, value] of Object.entries(state))
36
+ if (oldState[key] !== value) changedState[key] = value
37
+ meta.element.dataset.state = btoa(JSON.stringify(state))
38
+ })
39
+
40
+ export { state }
41
+ export default {
42
+ events,
43
+
44
+ // The UI state changes are split into chunks and sent to the server in an HTTP request header.
45
+ // Max size for an HTTP header is around 4k or 4,000 bytes.
46
+ // A Base64 character is an 8-bit-padded ASCII character... or 1 byte
47
+ //
48
+ // SEE: lib/state.rb - for info on how `state` is serialized/deserialized
49
+ get payloadChunks () {
50
+ return btoa(JSON.stringify(changedState)).match(/.{1,2000}/g)
51
+ }
52
+ }
@@ -0,0 +1,36 @@
1
+ import meta from '../meta'
2
+ import { dispatch, stateEvents as events } from '../events'
3
+
4
+ let head
5
+
6
+ function observable (object, parent = null) {
7
+ if (!object || typeof object !== 'object') return object
8
+
9
+ const proxy = new Proxy(object, {
10
+ deleteProperty (target, key) {
11
+ dispatch(events.beforeStateChange, meta.element, { state: head })
12
+ delete target[key]
13
+ dispatch(events.stateChange, meta.element, { state: head })
14
+ return true
15
+ },
16
+
17
+ set (target, key, value, receiver) {
18
+ dispatch(events.beforeStateChange, meta.element, { state: head })
19
+ target[key] = observable(value, this)
20
+ dispatch(events.stateChange, meta.element, { state: head })
21
+ return true
22
+ }
23
+ })
24
+
25
+ if (Array.isArray(object)) {
26
+ object.forEach((value, index) => (object[index] = observable(value, proxy)))
27
+ } else if (typeof object === 'object') {
28
+ for (const [key, value] of Object.entries(object))
29
+ object[key] = observable(value, proxy)
30
+ }
31
+
32
+ if (!parent) head = proxy
33
+ return proxy
34
+ }
35
+
36
+ export default observable
@@ -1,5 +1,7 @@
1
1
  import meta from './meta'
2
+ import state from './state'
2
3
  import renderer from './renderer'
4
+ import { dispatch } from './events'
3
5
  import lifecycle from './lifecycle'
4
6
 
5
7
  const frameSources = {}
@@ -8,6 +10,8 @@ const frameSources = {}
8
10
  addEventListener('turbo:before-fetch-request', event => {
9
11
  const frame = event.target.closest('turbo-frame')
10
12
  const { fetchOptions } = event.detail
13
+
14
+ // reflex invoked and busy
11
15
  if (meta.busy) {
12
16
  let acceptHeaders = [
13
17
  'text/vnd.turbo-reflex.html',
@@ -17,12 +21,14 @@ addEventListener('turbo:before-fetch-request', event => {
17
21
  .filter(entry => entry && entry.trim().length > 0)
18
22
  .join(', ')
19
23
  fetchOptions.headers['Accept'] = acceptHeaders
24
+ fetchOptions.headers['TurboReflex-Token'] = meta.token
20
25
  }
21
- fetchOptions.headers['TurboReflex-Token'] = meta.token
22
- meta.uiStateBase64Chunks.forEach(
26
+
27
+ // always send state
28
+ state.payloadChunks.forEach(
23
29
  (chunk, i) =>
24
30
  (fetchOptions.headers[
25
- `TurboReflex-UiState-${i.toString().padStart(6, '0')}`
31
+ `TurboReflex-State-${i.toString().padStart(4, '0')}`
26
32
  ] = chunk)
27
33
  )
28
34
  })
@@ -37,7 +43,12 @@ addEventListener('turbo:before-fetch-response', event => {
37
43
  if (response.header('TurboReflex')) {
38
44
  if (response.statusCode < 200 || response.statusCode > 399) {
39
45
  const error = `Server returned a ${response.statusCode} status code! TurboReflex requires 2XX-3XX status codes.`
40
- lifecycle.dispatchClientError({ ...event.detail, error })
46
+ dispatch(
47
+ lifecycle.events.clientError,
48
+ document,
49
+ { ...event.detail, error },
50
+ true
51
+ )
41
52
  }
42
53
 
43
54
  if (response.header('TurboReflex') === 'Append') {
@@ -6,18 +6,19 @@
6
6
  # Reflexes are executed via a before_action in the Rails controller lifecycle.
7
7
  # They have access to the following methods and properties.
8
8
  #
9
- # * dom_id ....................... The Rails dom_id helper
10
- # * dom_id_selector .............. Returns a CSS selector for a dom_id
11
- # * controller ................... The Rails controller processing the HTTP request
12
- # * element ...................... A struct that represents the DOM element that triggered the reflex
13
- # * params ....................... Reflex specific params (frame_id, element, etc.)
14
- # * render ....................... Renders Rails templates, partials, etc. (doesn't halt controller request handling)
15
- # * render_response .............. Renders a full controller response
16
- # * renderer ..................... An ActionController::Renderer
17
- # * prevent_controller_action .... Prevents the rails controller/action from running (i.e. the reflex handles the response entirely)
18
- # * turbo_stream ................. A Turbo Stream TagBuilder
19
- # * turbo_streams ................ A list of Turbo Streams to append to the response (also aliased as streams)
20
- # * ui_state ..................... An object that stores ephemeral UI state
9
+ # * dom_id ...................... The Rails dom_id helper
10
+ # * dom_id_selector ............. Returns a CSS selector for a dom_id
11
+ # * controller .................. The Rails controller processing the HTTP request
12
+ # * element ..................... A struct that represents the DOM element that triggered the reflex
13
+ # * morph ....................... Appends a Turbo Stream to morph a DOM element
14
+ # * params ...................... Reflex specific params (frame_id, element, etc.)
15
+ # * render ...................... Renders Rails templates, partials, etc. (doesn't halt controller request handling)
16
+ # * render_response ............. Renders a full controller response
17
+ # * renderer .................... An ActionController::Renderer
18
+ # * prevent_controller_action ... Prevents the rails controller/action from running (i.e. the reflex handles the response entirely)
19
+ # * turbo_stream ................ A Turbo Stream TagBuilder
20
+ # * turbo_streams ............... A list of Turbo Streams to append to the response (also aliased as streams)
21
+ # * state ....................... An object that stores ephemeral `state`
21
22
  #
22
23
  class TurboReflex::Base
23
24
  class << self
@@ -64,13 +65,12 @@ class TurboReflex::Base
64
65
  attr_reader :controller, :turbo_streams
65
66
  alias_method :streams, :turbo_streams
66
67
 
67
- delegate :dom_id, to: :"controller.view_context"
68
- delegate :render, to: :renderer
68
+ delegate :dom_id, :render, to: :"controller.view_context"
69
69
  delegate(
70
70
  :controller_action_prevented?,
71
71
  :render_response,
72
72
  :turbo_stream,
73
- :ui_state,
73
+ :state,
74
74
  to: :@runner
75
75
  )
76
76
 
@@ -84,6 +84,10 @@ class TurboReflex::Base
84
84
  "##{dom_id(...)}"
85
85
  end
86
86
 
87
+ def morph(selector, html)
88
+ turbo_streams << turbo_stream.invoke("morph", args: [html], selector: selector)
89
+ end
90
+
87
91
  # default reflex invoked when method not specified
88
92
  def noop
89
93
  end
@@ -96,22 +100,22 @@ class TurboReflex::Base
96
100
  @element ||= begin
97
101
  attributes = params[:element_attributes]
98
102
  attrs = attributes.keys.each_with_object({}) do |key, memo|
103
+ memo[:aria] ||= {}
99
104
  memo[:dataset] ||= {}
100
105
  if key.start_with?("data_")
101
106
  memo[:dataset][key[5..].parameterize.underscore.to_sym] = attributes[key]
107
+ elsif key.start_with?("aria_")
108
+ memo[:aria][key[5..].parameterize.underscore.to_sym] = attributes[key]
102
109
  else
103
110
  memo[key.parameterize.underscore.to_sym] = attributes[key]
104
111
  end
105
112
  end
106
- attrs[:dataset] = Struct.new(*attrs[:dataset].keys).new(*attrs[:dataset].values)
107
- Struct.new(*attrs.keys).new(*attrs.values)
113
+ attrs[:aria] = OpenStruct.new(attrs[:aria])
114
+ attrs[:dataset] = OpenStruct.new(attrs[:dataset])
115
+ OpenStruct.new attrs
108
116
  end
109
117
  end
110
118
 
111
- def renderer
112
- ActionController::Renderer.for controller.class, controller.request.env
113
- end
114
-
115
119
  def should_prevent_controller_action?(method_name)
116
120
  self.class.should_prevent_controller_action? self, method_name
117
121
  end
@@ -15,7 +15,7 @@ class TurboReflex::ControllerPack
15
15
  :controller_action_prevented?,
16
16
  :meta_tag,
17
17
  :run,
18
- :ui_state,
18
+ :state,
19
19
  :update_response,
20
20
  to: :runner
21
21
  )
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboReflex
4
+ class InvalidClassError < StandardError
5
+ end
6
+
7
+ class InvalidMethodError < StandardError
8
+ end
9
+
10
+ class InvalidTokenError < StandardError
11
+ end
12
+ end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
3
4
  require_relative "sanitizer"
4
- require_relative "ui_state"
5
+ require_relative "state_manager"
5
6
 
6
7
  class TurboReflex::Runner
7
- attr_reader :controller, :ui_state
8
+ attr_reader :controller, :state_manager
9
+ alias_method :state, :state_manager
8
10
 
9
11
  delegate_missing_to :controller
10
12
 
11
13
  def initialize(controller)
12
14
  @controller = controller
13
- @ui_state = TurboReflex::UiState.new(controller)
15
+ @state_manager = TurboReflex::StateManager.new(self)
14
16
  end
15
17
 
16
18
  def meta_tag
@@ -19,7 +21,7 @@ class TurboReflex::Runner
19
21
  id: "turbo-reflex",
20
22
  name: "turbo-reflex",
21
23
  content: masked_token,
22
- data: {busy: false, ui_state: ui_state.serialize}
24
+ data: {busy: false, state: state_manager.payload}
23
25
  }
24
26
  view_context.tag("meta", options).html_safe
25
27
  end
@@ -30,9 +32,26 @@ class TurboReflex::Runner
30
32
 
31
33
  def reflex_valid?
32
34
  return false unless reflex_requested?
33
- return false unless valid_client_token?
34
- return false unless reflex_instance.is_a?(TurboReflex::Base)
35
- reflex_instance.respond_to? reflex_method_name
35
+
36
+ # validate class
37
+ unless reflex_instance.is_a?(TurboReflex::Base)
38
+ raise TurboReflex::InvalidClassError,
39
+ "`#{reflex_class_name}` is not a subclass of `TurboReflex::Base`!"
40
+ end
41
+
42
+ # validate method
43
+ unless reflex_instance.respond_to?(reflex_method_name)
44
+ raise TurboReflex::InvalidMethodError,
45
+ "`#{reflex_class_name}` does not define the public method `#{reflex_method_name}`!"
46
+ end
47
+
48
+ # validate csrf token
49
+ unless valid_client_token?
50
+ raise TurboReflex::InvalidTokenError,
51
+ "CSRF token mismatch! The request header `TurboReflex-Token: #{client_token}` does not match the expected value of `#{server_token}`."
52
+ end
53
+
54
+ true
36
55
  end
37
56
 
38
57
  def reflex_params
@@ -103,10 +122,12 @@ class TurboReflex::Runner
103
122
  prevent_controller_action if should_prevent_controller_action?
104
123
  rescue => error
105
124
  @reflex_errored = true
125
+ raise error if controller_action_prevented?
106
126
  prevent_controller_action error: error
107
127
  end
108
128
 
109
129
  def prevent_controller_action(error: nil)
130
+ return if controller_action_prevented?
110
131
  @controller_action_prevented = true
111
132
 
112
133
  if error
@@ -117,17 +138,18 @@ class TurboReflex::Runner
117
138
  append_success_to_response
118
139
  end
119
140
 
120
- ui_state.set_cookie
141
+ append_meta_tag_to_response_body # called before `set_cookie` so all state is emitted to the DOM
142
+ state_manager.set_cookie # truncates state to stay within cookie size limits (4k)
121
143
  end
122
144
 
123
145
  def update_response
146
+ return if controller_action_prevented?
124
147
  return if @update_response_performed
125
148
  @update_response_performed = true
126
149
 
127
- append_meta_tag_to_response_body
128
- return if controller_action_prevented?
150
+ append_meta_tag_to_response_body # called before `set_cookie` so all state is emitted to the DOM
151
+ state_manager.set_cookie # truncates state to stay within cookie size limits (4k)
129
152
  append_success_to_response if reflex_succeeded?
130
- ui_state.set_cookie
131
153
  end
132
154
 
133
155
  def render_response(html: "", status: nil, headers: {TurboReflex: :Append})
@@ -139,16 +161,16 @@ class TurboReflex::Runner
139
161
  @turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
140
162
  end
141
163
 
164
+ def message_verifier
165
+ ActiveSupport::MessageVerifier.new session.id.to_s, digest: "SHA256"
166
+ end
167
+
142
168
  private
143
169
 
144
170
  def parsed_reflex_params
145
171
  @parsed_reflex_params ||= JSON.parse(params[:turbo_reflex])
146
172
  end
147
173
 
148
- def message_verifier
149
- ActiveSupport::MessageVerifier.new Rails.application.secret_key_base, digest: "SHA256"
150
- end
151
-
152
174
  def content_sanitizer
153
175
  TurboReflex::Sanitizer.instance
154
176
  end
@@ -166,7 +188,6 @@ class TurboReflex::Runner
166
188
  end
167
189
 
168
190
  def valid_client_token?
169
- return true # TODO: get this check working
170
191
  return false unless client_token.present?
171
192
  return false unless message_verifier.valid_message?(client_token)
172
193
  unmasked_client_token = message_verifier.verify(client_token)
@@ -179,7 +200,7 @@ class TurboReflex::Runner
179
200
  end
180
201
 
181
202
  def response_status
182
- return :multiple_choices if :should_redirect?
203
+ return :multiple_choices if should_redirect?
183
204
  :ok
184
205
  end
185
206
 
@@ -209,7 +230,7 @@ class TurboReflex::Runner
209
230
 
210
231
  def append_meta_tag_to_response_body
211
232
  session[:turbo_reflex_token] = new_token
212
- append_to_response_body turbo_stream.replace("turbo-reflex", meta_tag)
233
+ append_to_response_body turbo_stream.invoke("morph", args: [meta_tag], selector: "#turbo-reflex")
213
234
  end
214
235
 
215
236
  def append_success_event_to_response_body
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::State
4
+ class << self
5
+ def serialize_base64(data)
6
+ Base64.urlsafe_encode64 data.to_json, padding: false
7
+ end
8
+
9
+ def deserialize_base64(string)
10
+ return {} if string.blank?
11
+ JSON.parse Base64.urlsafe_decode64(string)
12
+ end
13
+
14
+ def serialize(data)
15
+ dump = Marshal.dump(data)
16
+ deflated = Zlib::Deflate.deflate(dump, Zlib::BEST_COMPRESSION)
17
+ Base64.urlsafe_encode64 deflated
18
+ end
19
+
20
+ def deserialize(string)
21
+ return {} if string.blank?
22
+ decoded = Base64.urlsafe_decode64(string)
23
+ inflated = Zlib::Inflate.inflate(decoded)
24
+ Marshal.load inflated
25
+ end
26
+ end
27
+
28
+ def initialize(ordinal_payload = nil)
29
+ @internal_keys = []
30
+ @internal_data = {}.with_indifferent_access
31
+
32
+ self.class.deserialize(ordinal_payload).each do |(key, value)|
33
+ write key, value
34
+ end
35
+ end
36
+
37
+ delegate :size, to: :internal_data
38
+ delegate :include?, :has_key?, :key?, :member?, to: :internal_data
39
+
40
+ def cache_key
41
+ "turbo-reflex/ui-state/#{Base64.urlsafe_encode64 Digest::MD5.hexdigest(payload), padding: false}"
42
+ end
43
+
44
+ def read(*keys, default: nil)
45
+ value = internal_data[key_for(*keys)]
46
+ value = write(*keys, default) if value.nil? && default
47
+ value
48
+ end
49
+
50
+ def write(*keys, value)
51
+ key = key_for(*keys)
52
+ internal_keys.delete key if internal_keys.include?(key)
53
+ internal_keys << key
54
+ internal_data[key] = value
55
+ value
56
+ end
57
+
58
+ def payload
59
+ self.class.serialize_base64 internal_data
60
+ end
61
+
62
+ def ordinal_payload
63
+ self.class.serialize internal_list
64
+ end
65
+
66
+ def shrink!
67
+ @internal_data = shrink(internal_data).with_indifferent_access
68
+ @internal_keys = internal_keys & internal_data.keys
69
+ end
70
+
71
+ def prune!(max_bytesize: 2.kilobytes)
72
+ return if internal_keys.blank?
73
+ return if internal_data.blank?
74
+
75
+ percentage = max_bytesize > 0 ? ordinal_payload.bytesize / max_bytesize.to_f : 0
76
+ while percentage > 1
77
+ keys_to_keep = internal_keys.slice((internal_keys.length - (internal_keys.length / percentage).floor)..-1)
78
+ keys_to_remove = internal_keys - keys_to_keep
79
+ @internal_keys = keys_to_keep
80
+ keys_to_remove.each { |key| internal_data.delete key }
81
+ percentage = max_bytesize > 0 ? ordinal_payload.bytesize / max_bytesize.to_f : 0
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :internal_keys
88
+ attr_reader :internal_data
89
+
90
+ def internal_list
91
+ internal_keys.map { |key| [key, internal_data[key]] }
92
+ end
93
+
94
+ def key_for(*keys)
95
+ keys.map { |key| key.try(:cache_key) || key.to_s }.join("/")
96
+ end
97
+
98
+ def shrink(obj)
99
+ case obj
100
+ when Array
101
+ obj.each_with_object([]) do |value, memo|
102
+ value = shrink(value)
103
+ memo << value if value.present?
104
+ end
105
+ when Hash
106
+ obj.each_with_object({}.with_indifferent_access) do |(key, value), memo|
107
+ value = shrink(value)
108
+ memo[key] = value if value.present?
109
+ end
110
+ else
111
+ obj
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "state"
4
+
5
+ # Class used to hold ephemeral state related to the rendered UI.
6
+ #
7
+ # Examples:
8
+ #
9
+ # - Sidebar open/closed state
10
+ # - Tree view open/closed state
11
+ # - Accordion collapsed/expanded state
12
+ # - Customized layout / presentation
13
+ # - Applied data filters
14
+ # - Number of data rows to display etc.
15
+ #
16
+ class TurboReflex::StateManager
17
+ include ActiveModel::Dirty
18
+
19
+ class << self
20
+ attr_writer :cookie_max_bytesize
21
+
22
+ def cookie_max_bytesize
23
+ @cookie_max_bytesize ||= 2.kilobytes
24
+ end
25
+
26
+ def state_override_blocks
27
+ @state_overrides ||= {}
28
+ end
29
+
30
+ def add_state_override_block(controller_name, block)
31
+ state_override_blocks[controller_name] = block
32
+ end
33
+
34
+ def state_override_block(controller)
35
+ return nil if state_override_blocks.blank?
36
+ ancestor = controller.class.ancestors.find { |a| state_override_blocks[a.name] }
37
+ state_override_blocks[ancestor.name]
38
+ end
39
+ end
40
+
41
+ # For ActiveModel::Dirty tracking
42
+ define_attribute_methods :state
43
+
44
+ delegate :request, :response, to: :"runner.controller"
45
+
46
+ def initialize(runner)
47
+ @runner = runner
48
+ @state = TurboReflex::State.new(cookie) # server state as stored in the cookie
49
+
50
+ # Apply server state overrides (i.e. state stored in databases like Redis, Postgres, etc...)
51
+ state_override_block = self.class.state_override_block(runner.controller)
52
+ if state_override_block
53
+ server_data = runner.controller.instance_eval(&state_override_block).with_indifferent_access
54
+ server_data.each { |key, val| self[key] = val }
55
+ end
56
+
57
+ # Merge client state into server state (i.e. optimistic state)
58
+ # NOTE: Client state HTTP headers are only sent if/when state has changed on the client (only the changes are sent).
59
+ # This prevents race conditions (state mismatch) caused when frame and XHR requests emit immediately
60
+ # before the <meta id="turbo-reflex"> has been updated with the latest state from the server.
61
+ client_data = TurboReflex::State.deserialize_base64(header).with_indifferent_access
62
+ client_data.each { |key, val| self[key] = val }
63
+ end
64
+
65
+ delegate :cache_key, :payload, to: :state
66
+
67
+ def [](*keys, default: nil)
68
+ state.read(*keys, default: default)
69
+ end
70
+
71
+ def []=(*keys, value)
72
+ state_will_change! if value != self[*keys]
73
+ state.write(*keys, value)
74
+ end
75
+
76
+ def set_cookie
77
+ return unless changed?
78
+ state.shrink!
79
+ state.prune! max_bytesize: TurboReflex::StateManager.cookie_max_bytesize
80
+ response.set_cookie "_turbo_reflex_state", value: state.ordinal_payload, path: "/", expires: 1.day.from_now
81
+ changes_applied
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :runner
87
+ attr_reader :state
88
+
89
+ def headers
90
+ request.headers.select { |(key, _)| key.match?(/TURBOREFLEX_STATE/i) }.sort
91
+ end
92
+
93
+ # State that exists on the client.
94
+ def header
95
+ headers.map(&:last).join
96
+ end
97
+
98
+ # State that the server last rendered with.
99
+ def cookie
100
+ request.cookies["_turbo_reflex_state"]
101
+ end
102
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboReflex
4
- VERSION = "0.0.10"
4
+ VERSION = "0.0.11"
5
5
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbo_reflex",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Reflexes for Turbo Frames that help you build robust reactive applications",
5
5
  "main": "app/javascript/index.js",
6
6
  "repository": "https://github.com/hopsoft/turbo_reflex",