turbo_reflex 0.0.6 → 0.0.7

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