turbo_reflex 0.0.10 → 0.0.11

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,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",