turbo_reflex 0.0.6 → 0.0.7

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.
@@ -1,55 +1,14 @@
1
- import LifecycleEvents from './lifecycle_events'
1
+ import schema from './schema.js'
2
+ import lifecycle from './lifecycle'
2
3
 
3
4
  function findClosestReflex (element) {
4
- return element.closest('[data-turbo-reflex]')
5
+ return element.closest(`[${schema.reflexAttribute}]`)
5
6
  }
6
7
 
7
8
  function findClosestFrame (element) {
8
9
  return element.closest('turbo-frame')
9
10
  }
10
11
 
11
- function findFrameId (element) {
12
- let id = element.dataset.turboFrame
13
- if (!id) {
14
- const frame = findClosestFrame(element)
15
- if (frame) id = frame.id
16
- }
17
- if (!id) {
18
- console.error(
19
- `The reflex element does not specify a frame!`,
20
- `Please move the reflex element inside a <turbo-frame> or set the 'data-turbo-frame' attribute.`,
21
- element
22
- )
23
- LifecycleEvents.dispatch(LifecycleEvents.missingFrameId, element, {
24
- element
25
- })
26
- }
27
- return id
28
- }
29
-
30
- function findFrame (id) {
31
- const frame = document.getElementById(id)
32
- if (!frame) {
33
- console.error(`The frame '${id}' does not exist!`)
34
- LifecycleEvents.dispatch(LifecycleEvents.missingFrame, document, { id })
35
- }
36
- return frame
37
- }
38
-
39
- function findFrameSrc (frame) {
40
- const frameSrc = frame.dataset.turboReflexSrc || frame.src
41
- if (!frameSrc) {
42
- console.error(
43
- `The the 'src' for <turbo-frame id='${frame.id}'> is unknown!`,
44
- `TurboReflex uses 'src' to (re)render frame content after the reflex is invoked.`,
45
- `Please set the 'src' or 'data-turbo-reflex-src' attribute on the <turbo-frame> element.`,
46
- frame
47
- )
48
- LifecycleEvents.dispatch(LifecycleEvents.missingFrameSrc, frame, { frame })
49
- }
50
- return frameSrc
51
- }
52
-
53
12
  function assignElementValueToPayload (element, payload = {}) {
54
13
  if (element.tagName.toLowerCase() !== 'select')
55
14
  return (payload.value = element.value)
@@ -64,8 +23,16 @@ function assignElementValueToPayload (element, payload = {}) {
64
23
  }
65
24
 
66
25
  function buildAttributePayload (element) {
26
+ // truncate long values to optimize payload size
27
+ // TODO: revisit this decision
28
+ const maxAttributeLength = 100
29
+ const maxValueLength = 500
30
+
67
31
  const payload = Array.from(element.attributes).reduce((memo, attr) => {
68
- memo[attr.name] = attr.value
32
+ let value = attr.value
33
+ if (typeof value === 'string' && value.length > maxAttributeLength)
34
+ value = value.slice(0, maxAttributeLength) + '...'
35
+ memo[attr.name] = value
69
36
  return memo
70
37
  }, {})
71
38
 
@@ -74,14 +41,26 @@ function buildAttributePayload (element) {
74
41
  payload.disabled = element.disabled
75
42
  assignElementValueToPayload(element, payload)
76
43
 
44
+ if (
45
+ typeof payload.value === 'string' &&
46
+ payload.value.length > maxValueLength
47
+ )
48
+ payload.value = payload.value.slice(0, maxValueLength) + '...'
49
+
50
+ delete payload.class
51
+ delete payload[schema.reflexAttribute]
52
+ delete payload[schema.frameAttribute]
77
53
  return payload
78
54
  }
79
55
 
80
- export {
56
+ export default {
57
+ buildAttributePayload,
81
58
  findClosestReflex,
82
59
  findClosestFrame,
83
- findFrameId,
84
- findFrame,
85
- findFrameSrc,
86
- buildAttributePayload
60
+ get metaElement () {
61
+ return document.getElementById('turbo-reflex')
62
+ },
63
+ get metaElementToken () {
64
+ return document.getElementById('turbo-reflex').getAttribute('content')
65
+ }
87
66
  }
@@ -0,0 +1,72 @@
1
+ import './turbo'
2
+ import schema from './schema.js'
3
+ import activity from './activity'
4
+ import delegates from './delegates'
5
+ import drivers from './drivers'
6
+ import elements from './elements'
7
+ import lifecycle from './lifecycle'
8
+ import logger from './logger'
9
+ import urls from './urls'
10
+ import uuids from './uuids'
11
+
12
+ function invokeReflex (event) {
13
+ let element
14
+ let payload = {}
15
+
16
+ try {
17
+ element = elements.findClosestReflex(event.target)
18
+ if (!element) return
19
+ if (!delegates.isRegistered(event.type, element.tagName)) return
20
+
21
+ const driver = drivers.find(element)
22
+
23
+ // payload sent to server (also used for lifecycle event.detail)
24
+ payload = {
25
+ id: `reflex-${uuids.v4()}`,
26
+ name: element.dataset.turboReflex,
27
+ driver: driver.name,
28
+ src: driver.src,
29
+ frameId: driver.frame ? driver.frame.id : null,
30
+ elementId: element.id.length > 0 ? element.id : null,
31
+ elementAttributes: elements.buildAttributePayload(element),
32
+ startedAt: new Date().getTime()
33
+ }
34
+
35
+ activity.add(payload)
36
+ lifecycle.dispatch(lifecycle.events.start, element, payload)
37
+
38
+ if (driver.name !== 'form') event.preventDefault()
39
+
40
+ switch (driver.name) {
41
+ case 'form':
42
+ return driver.invokeReflex(element, payload)
43
+ case 'frame':
44
+ return driver.invokeReflex(driver.frame, payload)
45
+ case 'window':
46
+ return driver.invokeReflex(payload)
47
+ }
48
+ } catch (error) {
49
+ lifecycle.dispatch(lifecycle.events.clientError, element, {
50
+ error,
51
+ ...payload
52
+ })
53
+ }
54
+ }
55
+
56
+ // wire things up and setup defaults for event delegation
57
+ delegates.handler = invokeReflex
58
+ delegates.register('change', ['input', 'select', 'textarea'])
59
+ delegates.register('submit', ['form'])
60
+ delegates.register('click', ['*'])
61
+
62
+ export default {
63
+ schema,
64
+ logger,
65
+ registerEventDelegate: delegates.register,
66
+ get eventDelegates () {
67
+ return { ...delegates.events }
68
+ },
69
+ get lifecycleEvents () {
70
+ return [...Object.values(lifecycle.events)]
71
+ }
72
+ }
@@ -0,0 +1,40 @@
1
+ import activity from './activity'
2
+
3
+ const events = {
4
+ start: 'turbo-reflex:start',
5
+ success: 'turbo-reflex:success',
6
+ finish: 'turbo-reflex:finish',
7
+ abort: 'turbo-reflex:abort',
8
+ clientError: 'turbo-reflex:client-error',
9
+ serverError: 'turbo-reflex:server-error'
10
+ }
11
+
12
+ function dispatch (name, target = document, detail = {}, raise = false) {
13
+ try {
14
+ target = target || document
15
+ const event = new CustomEvent(name, {
16
+ detail,
17
+ cancelable: false,
18
+ bubbles: true
19
+ })
20
+ target.dispatchEvent(event)
21
+ } catch (error) {
22
+ if (raise) throw error
23
+ dispatch(events.clientError, target, { error, ...detail }, true)
24
+ }
25
+ }
26
+
27
+ function finish (event) {
28
+ event.detail.endedAt = new Date().getTime()
29
+ event.detail.milliseconds = event.detail.endedAt - event.detail.startedAt
30
+ setTimeout(() => dispatch(events.finish, event.target, event.detail), 10)
31
+ }
32
+
33
+ addEventListener(events.serverError, finish)
34
+ addEventListener(events.success, finish)
35
+ addEventListener(events.finish, event => activity.remove(event.detail.id), true)
36
+
37
+ export default {
38
+ dispatch,
39
+ events
40
+ }
@@ -0,0 +1,34 @@
1
+ import lifecycle from './lifecycle'
2
+
3
+ let currentLevel = 'unknown'
4
+
5
+ const logLevels = {
6
+ debug: Object.values(lifecycle.events),
7
+ info: Object.values(lifecycle.events),
8
+ warn: [
9
+ lifecycle.events.abort,
10
+ lifecycle.events.clientError,
11
+ lifecycle.events.serverError
12
+ ],
13
+ error: [lifecycle.events.clientError, lifecycle.events.serverError],
14
+ unknown: []
15
+ }
16
+
17
+ Object.values(lifecycle.events).forEach(name => {
18
+ addEventListener(name, event => {
19
+ if (logLevels[currentLevel].includes(event.type)) {
20
+ const level = currentLevel === 'debug' ? 'log' : currentLevel
21
+ console[level](event.type, event.detail)
22
+ }
23
+ })
24
+ })
25
+
26
+ export default {
27
+ get level () {
28
+ return currentLevel
29
+ },
30
+ set level (value) {
31
+ if (!Object.keys(logLevels).includes(value)) value = 'unknown'
32
+ return (currentLevel = value)
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ const schema = {
2
+ frameAttribute: 'data-turbo-frame',
3
+ reflexAttribute: 'data-turbo-reflex'
4
+ }
5
+
6
+ export default { ...schema }
@@ -0,0 +1,24 @@
1
+ import elements from './elements'
2
+
3
+ const frameSources = {}
4
+
5
+ // fires before making a turbo HTTP request
6
+ addEventListener('turbo:before-fetch-request', event => {
7
+ const frame = event.target.closest('turbo-frame')
8
+ const { fetchOptions } = event.detail
9
+ fetchOptions.headers['TurboReflex-Token'] = elements.metaElementToken
10
+ })
11
+
12
+ // fires after receiving a turbo HTTP response
13
+ addEventListener('turbo:before-fetch-response', event => {
14
+ const frame = event.target.closest('turbo-frame')
15
+ if (frame) frameSources[frame.id] = frame.src
16
+ })
17
+
18
+ // fires when a frame element is navigated and finishes loading
19
+ addEventListener('turbo:frame-load', event => {
20
+ const frame = event.target.closest('turbo-frame')
21
+ frame.dataset.turboReflexSrc =
22
+ frameSources[frame.id] || frame.src || frame.dataset.turboReflexSrc
23
+ delete frameSources[frame.id]
24
+ })
@@ -0,0 +1,9 @@
1
+ function build (urlString, payload = {}) {
2
+ const a = document.createElement('a')
3
+ a.href = urlString
4
+ const url = new URL(a)
5
+ url.searchParams.set('turbo_reflex', JSON.stringify(payload))
6
+ return url
7
+ }
8
+
9
+ export default { build }
@@ -0,0 +1,10 @@
1
+ function v4 () {
2
+ return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
3
+ (
4
+ c ^
5
+ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
6
+ ).toString(16)
7
+ )
8
+ }
9
+
10
+ export default { v4 }
@@ -1,23 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Reflex instances have access to the following methods and properties.
4
+ #
5
+ # * controller ........ The Rails controller processing the HTTP request
6
+ # * element ........... A struct that represents the DOM element that triggered the reflex
7
+ # * hijack_response ... Hijacks the response, must be called last (halts further request handling by the controller)
8
+ # * params ............ Reflex specific params (frame_id, element, etc.)
9
+ # * render ............ Renders Rails templates, partials, etc. (doesn't halt controller request handling)
10
+ # * renderer .......... An ActionController::Renderer
11
+ # * turbo_stream ...... A Turbo Stream TagBuilder
12
+ # * turbo_streams ..... A list of Turbo Streams to append to the response
13
+ #
3
14
  class TurboReflex::Base
15
+ class << self
16
+ def response_hijackers
17
+ @response_hijackers ||= Set.new
18
+ end
19
+
20
+ def hijack_response(options = {})
21
+ response_hijackers << options.with_indifferent_access
22
+ end
23
+
24
+ def should_hijack_response?(reflex, method_name)
25
+ method_name = method_name.to_s
26
+ match = response_hijackers.find do |options|
27
+ only = options[:only] || []
28
+ only = [only] unless only.is_a?(Array)
29
+ only.map!(&:to_s)
30
+
31
+ except = options[:except] || []
32
+ except = [except] unless except.is_a?(Array)
33
+ except.map!(&:to_s)
34
+
35
+ options.blank? || only.include?(method_name) || except.exclude?(method_name)
36
+ end
37
+
38
+ return false if match.nil?
39
+
40
+ if match[:if].present?
41
+ case match[:if]
42
+ when Symbol then reflex.public_send(match[:if])
43
+ when Proc then reflex.instance_exec { match[:if].call reflex }
44
+ end
45
+ elsif match[:unless].present?
46
+ case match[:unless]
47
+ when Symbol then !reflex.public_send(match[:unless])
48
+ when Proc then !(reflex.instance_exec { match[:unless].call(reflex) })
49
+ end
50
+ else
51
+ true
52
+ end
53
+ end
54
+ end
55
+
4
56
  attr_reader :controller, :turbo_streams
5
57
 
6
58
  delegate :render, to: :renderer
59
+ delegate :hijack_response, :turbo_stream, to: :@runner
7
60
 
8
- def initialize(controller)
9
- @controller = controller
61
+ def initialize(runner)
62
+ @runner = runner
63
+ @controller = runner.controller
10
64
  @turbo_streams = Set.new
11
65
  end
12
66
 
13
67
  def params
14
- controller.turbo_reflex_params
68
+ @runner.reflex_params
15
69
  end
16
70
 
17
71
  def element
18
72
  @element ||= begin
19
- keys = params[:element].keys.map { |key| key.to_s.parameterize.underscore.to_sym }
20
- values = params[:element].values
73
+ keys = params[:element_attributes].keys.map { |key| key.to_s.parameterize.underscore.to_sym }
74
+ values = params[:element_attributes].values
21
75
 
22
76
  unless keys.include? :value
23
77
  keys << :value
@@ -28,10 +82,6 @@ class TurboReflex::Base
28
82
  end
29
83
  end
30
84
 
31
- def turbo_stream
32
- @turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
33
- end
34
-
35
85
  def renderer
36
86
  ActionController::Renderer.for controller.class, controller.request.env
37
87
  end
@@ -2,23 +2,15 @@
2
2
 
3
3
  require "turbo-rails"
4
4
  require_relative "version"
5
- require_relative "errors"
6
5
  require_relative "sanitizer"
6
+ require_relative "runner"
7
7
  require_relative "base"
8
8
 
9
9
  class TurboReflex::Engine < ::Rails::Engine
10
- # isolate_namespace TurboReflex
11
-
12
10
  config.turbo_reflex = ActiveSupport::OrderedOptions.new
13
11
  initializer "turbo_reflex.configuration" do
14
12
  config.to_prepare do |app|
15
13
  ::ActionController::Base.send :include, TurboReflex::Controller
16
14
  end
17
-
18
- config.after_initialize do |app|
19
- app.routes.draw do
20
- mount TurboReflex::Engine => "/turbo_reflex"
21
- end
22
- end
23
15
  end
24
16
  end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::Runner
4
+ attr_reader :controller
5
+
6
+ delegate_missing_to :controller
7
+
8
+ def initialize(controller)
9
+ @controller = controller
10
+ end
11
+
12
+ def meta_tag
13
+ masked_token = message_verifier.generate(new_token)
14
+ options = {
15
+ id: "turbo-reflex",
16
+ name: "turbo-reflex",
17
+ content: masked_token,
18
+ data: {busy: false}
19
+ }
20
+ view_context.tag("meta", options).html_safe
21
+ end
22
+
23
+ def reflex_requested?
24
+ reflex_params.present?
25
+ end
26
+
27
+ def reflex_valid?
28
+ return false unless reflex_requested?
29
+ return false unless valid_client_token?
30
+ return false unless reflex_instance.is_a?(TurboReflex::Base)
31
+ reflex_instance.respond_to? reflex_method_name
32
+ end
33
+
34
+ def reflex_params
35
+ return ActionController::Parameters.new if params[:turbo_reflex].nil?
36
+ @reflex_params ||= begin
37
+ payload = parsed_reflex_params.deep_transform_keys(&:underscore)
38
+ ActionController::Parameters.new(payload).permit!
39
+ end
40
+ end
41
+
42
+ def reflex_element
43
+ return nil if reflex_params.blank?
44
+ @reflex_element ||= Struct
45
+ .new(*reflex_params[:element_attributes].keys.map { |key| key.to_s.parameterize.underscore.to_sym })
46
+ .new(*reflex_params[:element_attributes].values)
47
+ end
48
+
49
+ def reflex_name
50
+ return nil unless reflex_requested?
51
+ reflex_params[:name]
52
+ end
53
+
54
+ def reflex_class_name
55
+ return nil unless reflex_requested?
56
+ reflex_name.split("#").first
57
+ end
58
+
59
+ def reflex_method_name
60
+ return nil unless reflex_requested?
61
+ reflex_name.split("#").last
62
+ end
63
+
64
+ def reflex_class
65
+ @reflex_class ||= reflex_class_name&.safe_constantize
66
+ end
67
+
68
+ def reflex_instance
69
+ @reflex_instance ||= reflex_class&.new(self)
70
+ end
71
+
72
+ def reflex_performed?
73
+ !!@reflex_performed
74
+ end
75
+
76
+ def reflex_errored?
77
+ !!@reflex_errored
78
+ end
79
+
80
+ def reflex_succeeded?
81
+ reflex_performed? && !reflex_errored?
82
+ end
83
+
84
+ def run
85
+ return unless reflex_valid?
86
+ return if reflex_performed?
87
+ @reflex_performed = true
88
+ reflex_instance.public_send reflex_method_name
89
+ hijack_response if reflex_class.should_hijack_response?(reflex_instance, reflex_method_name)
90
+ rescue => e
91
+ response.status = :internal_server_error
92
+ @reflex_errored = true
93
+ message = "Error in #{reflex_name}! #{e.inspect}"
94
+ Rails.logger.error message
95
+ append_error_event_to_response_body message
96
+ end
97
+
98
+ def hijack_response
99
+ response.set_header "TurboReflex-Hijacked", true
100
+ render html: "", layout: false
101
+ append_to_response
102
+ end
103
+
104
+ def append_to_response
105
+ append_turbo_streams_to_response_body
106
+ append_meta_tag_to_response_body
107
+ append_success_event_to_response_body
108
+ end
109
+
110
+ def turbo_stream
111
+ @turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
112
+ end
113
+
114
+ private
115
+
116
+ def parsed_reflex_params
117
+ @parsed_reflex_params ||= JSON.parse(params[:turbo_reflex])
118
+ end
119
+
120
+ def message_verifier
121
+ ActiveSupport::MessageVerifier.new session.id.to_s, digest: "SHA256"
122
+ end
123
+
124
+ def content_sanitizer
125
+ TurboReflex::Sanitizer.instance
126
+ end
127
+
128
+ def new_token
129
+ @new_token ||= SecureRandom.urlsafe_base64(32)
130
+ end
131
+
132
+ def server_token
133
+ session[:turbo_reflex_token]
134
+ end
135
+
136
+ def client_token
137
+ (request.headers["TurboReflex-Token"] || reflex_params[:token]).to_s
138
+ end
139
+
140
+ def valid_client_token?
141
+ return false unless client_token.present?
142
+ return false unless message_verifier.valid_message?(client_token)
143
+ unmasked_client_token = message_verifier.verify(client_token)
144
+ unmasked_client_token == server_token
145
+ end
146
+
147
+ def response_type
148
+ body = response.body.to_s.strip
149
+ return :body if body.match?(/<\/\s*body.*>/i)
150
+ return :frame if body.match?(/<\/\s*turbo-frame.*>/i)
151
+ return :stream if body.match?(/<\/\s*turbo-stream.*>/i)
152
+ :unknown
153
+ end
154
+
155
+ def append_turbo_streams_to_response_body
156
+ return unless reflex_succeeded?
157
+ return unless reflex_instance&.turbo_streams.present?
158
+ append_to_response_body reflex_instance.turbo_streams.map(&:to_s).join.html_safe
159
+ end
160
+
161
+ def append_meta_tag_to_response_body
162
+ session[:turbo_reflex_token] = new_token
163
+ append_to_response_body turbo_stream.replace("turbo-reflex", meta_tag)
164
+ end
165
+
166
+ def append_success_event_to_response_body
167
+ return unless reflex_succeeded?
168
+ args = ["turbo-reflex:success", {bubbles: true, cancelable: false, detail: parsed_reflex_params}]
169
+ event = if reflex_element.try(:id).present?
170
+ turbo_stream.invoke :dispatch_event, args: args, selector: "##{reflex_element.id}"
171
+ else
172
+ turbo_stream.invoke :dispatch_event, args: args
173
+ end
174
+ append_to_response_body event
175
+ end
176
+
177
+ def append_error_event_to_response_body(message)
178
+ return unless reflex_errored?
179
+ args = ["turbo-reflex:server-error", {bubbles: true, cancelable: false, detail: parsed_reflex_params.merge(error: message)}]
180
+ event = if reflex_element.try(:id).present?
181
+ turbo_stream.invoke :dispatch_event, args: args, selector: "##{reflex_element.id}"
182
+ else
183
+ turbo_stream.invoke :dispatch_event, args: args
184
+ end
185
+ append_to_response_body event
186
+ end
187
+
188
+ def append_to_response_body(content)
189
+ sanitized_content = content_sanitizer.sanitize(content).html_safe
190
+
191
+ return if sanitized_content.blank?
192
+
193
+ case response_type
194
+ when :body then response.body.sub!(/<\/\s*body.*>/i, "#{sanitized_content}</body>")
195
+ when :frame then response.body.sub!(/<\/\s*turbo-frame.*>/i, "#{sanitized_content}</turbo-frame>")
196
+ else response.body << sanitized_content
197
+ end
198
+ end
199
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboReflex
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.7"
5
5
  end
data/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "turbo_reflex",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Reflexes for Turbo Frames that help you build robust reactive applications",
5
- "main": "app/javascript/turbo_reflex.js",
5
+ "main": "app/javascript/index.js",
6
6
  "repository": "https://github.com/hopsoft/turbo_reflex",
7
7
  "author": "Nate Hopkins (hopsoft) <natehop@gmail.com>",
8
8
  "license": "MIT",
9
9
  "peerDependencies": {
10
- "@hotwired/turbo-rails": ">= 7.1"
10
+ "@hotwired/turbo-rails": ">= 7.1",
11
+ "turbo_ready": ">= 0.1"
11
12
  },
12
13
  "devDependencies": {
13
14
  "esbuild": "^0.15.7",
@@ -17,6 +18,6 @@
17
18
  "rustywind": "^0.15.1"
18
19
  },
19
20
  "scripts": {
20
- "build": "esbuild app/javascript/turbo_reflex.js --bundle --minify --sourcemap --format=esm --outfile=app/assets/builds/turbo_reflex.js"
21
+ "build": "esbuild app/javascript/index.js --bundle --minify --sourcemap --format=esm --outfile=app/assets/builds/turbo_reflex.js"
21
22
  }
22
23
  }
data/turbo_reflex.gemspec CHANGED
@@ -20,6 +20,7 @@ Gem::Specification.new do |s|
20
20
 
21
21
  s.add_dependency "rails", ">= 6.1"
22
22
  s.add_dependency "turbo-rails", ">= 1.1"
23
+ s.add_dependency "turbo_ready", ">= 0.1"
23
24
 
24
25
  s.add_development_dependency "capybara"
25
26
  s.add_development_dependency "cuprite"