turbo_reflex 0.0.1 → 0.0.2

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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboReflex::Controller
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :perform_turbo_reflex, if: -> { turbo_reflex_requested? && turbo_reflex_valid? }
8
+ after_action :append_turbo_reflex_turbo_streams, if: :turbo_reflex_performed?
9
+ after_action :assign_turbo_reflex_token
10
+ helper_method :turbo_reflex_meta_tag, :turbo_reflex_performed?, :turbo_reflex_requested?
11
+ end
12
+
13
+ def turbo_reflex_meta_tag
14
+ masked_token = turbo_reflex_message_verifier.generate(new_turbo_reflex_token)
15
+ options = {id: "turbo-reflex-token", name: "turbo-reflex-token", content: masked_token}
16
+ view_context.tag("meta", options).html_safe
17
+ end
18
+
19
+ def turbo_reflex_params
20
+ return ActionController::Parameters.new if params[:turbo_reflex].nil?
21
+ @turbo_reflex_params ||= begin
22
+ payload = JSON.parse(params[:turbo_reflex]).deep_transform_keys(&:underscore)
23
+ ActionController::Parameters.new(payload).permit!
24
+ end
25
+ end
26
+
27
+ def turbo_reflex_requested?
28
+ return false unless client_turbo_reflex_token.present?
29
+ return false unless turbo_reflex_params.present?
30
+ true
31
+ end
32
+
33
+ def turbo_reflex_element
34
+ return nil if turbo_reflex_params.blank?
35
+ @turbo_reflex_element ||= Struct
36
+ .new(*turbo_reflex_params[:element].keys.map { |key| key.to_s.parameterize.underscore.to_sym })
37
+ .new(*turbo_reflex_params[:element].values)
38
+ end
39
+
40
+ def turbo_reflex_name
41
+ return nil unless turbo_reflex_requested?
42
+ turbo_reflex_element.data_turbo_reflex
43
+ end
44
+
45
+ def turbo_reflex_class_name
46
+ return nil unless turbo_reflex_requested?
47
+ turbo_reflex_name.split("#").first
48
+ end
49
+
50
+ def turbo_reflex_method_name
51
+ return nil unless turbo_reflex_requested?
52
+ turbo_reflex_name.split("#").last
53
+ end
54
+
55
+ def turbo_reflex_class
56
+ @turbo_reflex_class ||= turbo_reflex_class_name&.safe_constantize
57
+ end
58
+
59
+ def turbo_reflex_instance
60
+ @turbo_reflex_instance ||= turbo_reflex_class&.new(self)
61
+ end
62
+
63
+ def turbo_reflex_valid?
64
+ return false if request.get? && client_turbo_reflex_token.blank?
65
+ return false unless valid_turbo_reflex_token?
66
+ return false unless turbo_reflex_instance.is_a?(TurboReflex::Base)
67
+ turbo_reflex_instance.respond_to? turbo_reflex_method_name
68
+ end
69
+
70
+ def turbo_reflex_performed?
71
+ !!@turbo_reflex_performed
72
+ end
73
+
74
+ protected
75
+
76
+ def perform_turbo_reflex
77
+ turbo_reflex_instance.public_send turbo_reflex_method_name
78
+ @turbo_reflex_performed = true
79
+ end
80
+
81
+ def append_turbo_reflex_turbo_streams
82
+ return unless turbo_reflex_performed?
83
+ return unless turbo_reflex_instance&.turbo_streams.present?
84
+ append_turbo_reflex_content turbo_reflex_instance.turbo_streams.map(&:to_s).join
85
+ end
86
+
87
+ private
88
+
89
+ def turbo_reflex_message_verifier
90
+ ActiveSupport::MessageVerifier.new session.id.to_s, digest: "SHA256"
91
+ end
92
+
93
+ def client_turbo_reflex_token
94
+ (request.headers["Turbo-Reflex"] || turbo_reflex_params[:token]).to_s
95
+ end
96
+
97
+ def new_turbo_reflex_token
98
+ @new_turbo_reflex_token ||= SecureRandom.urlsafe_base64(32)
99
+ end
100
+
101
+ def current_turbo_reflex_token
102
+ session[:turbo_reflex_token]
103
+ end
104
+
105
+ def valid_turbo_reflex_token?
106
+ return false unless turbo_reflex_message_verifier.valid_message?(client_turbo_reflex_token)
107
+ unmasked_token = turbo_reflex_message_verifier.verify(client_turbo_reflex_token)
108
+ unmasked_token == current_turbo_reflex_token
109
+ end
110
+
111
+ def assign_turbo_reflex_token
112
+ return unless turbo_reflex_requested? || client_turbo_reflex_token.blank?
113
+ session[:turbo_reflex_token] = new_turbo_reflex_token
114
+ append_turbo_reflex_content turbo_stream.replace("turbo-reflex-token", turbo_reflex_meta_tag)
115
+ end
116
+
117
+ def turbo_reflex_response_type
118
+ body = response.body.to_s.strip
119
+ return :stream if body.ends_with?("</turbo-stream>")
120
+ return :frame if body.ends_with?("</turbo-frame>")
121
+ :default
122
+ end
123
+
124
+ def append_turbo_reflex_content(content)
125
+ sanitized_content = TurboReflex::Sanitizer.instance.sanitize(content).html_safe
126
+ case turbo_reflex_response_type
127
+ when :stream then response.body << sanitized_content
128
+ when :frame then response.body.sub!("</turbo-frame>", "#{sanitized_content}</turbo-frame>")
129
+ when :default then response.body.sub!("</body>", "#{sanitized_content}</body>")
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,87 @@
1
+ import LifecycleEvents from './lifecycle_events'
2
+
3
+ function findClosestReflex (element) {
4
+ return element.closest('[data-turbo-reflex]')
5
+ }
6
+
7
+ function findClosestFrame (element) {
8
+ return element.closest('turbo-frame')
9
+ }
10
+
11
+ function findFrameId (element) {
12
+ let id = element.dataset.turboReflexFrame || 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-reflex-frame' or '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
+ function assignElementValueToPayload (element, payload = {}) {
54
+ if (element.tagName.toLowerCase() !== 'select')
55
+ return (payload.value = element.value)
56
+
57
+ if (!element.multiple)
58
+ return (payload.value = element.options[element.selectedIndex].value)
59
+
60
+ payload.values = Array.from(element.options).reduce((memo, option) => {
61
+ if (option.selected) memo.push(option.value)
62
+ return memo
63
+ }, [])
64
+ }
65
+
66
+ function buildAttributePayload (element) {
67
+ const payload = Array.from(element.attributes).reduce((memo, attr) => {
68
+ memo[attr.name] = attr.value
69
+ return memo
70
+ }, {})
71
+
72
+ payload.tag = element.tagName
73
+ payload.checked = element.checked
74
+ payload.disabled = element.disabled
75
+ assignElementValueToPayload(element, payload)
76
+
77
+ return payload
78
+ }
79
+
80
+ export {
81
+ findClosestReflex,
82
+ findClosestFrame,
83
+ findFrameId,
84
+ findFrame,
85
+ findFrameSrc,
86
+ buildAttributePayload
87
+ }
@@ -0,0 +1,34 @@
1
+ const registeredEvents = {}
2
+ let eventListener
3
+
4
+ function registerEventListener (fn) {
5
+ eventListener = fn
6
+ }
7
+
8
+ function registerEvent (eventName, tagNames) {
9
+ registeredEvents[eventName] = tagNames
10
+ document.addEventListener(eventName, eventListener, true)
11
+ }
12
+
13
+ function isRegisteredEvent (eventName, tagName) {
14
+ tagName = tagName.toLowerCase()
15
+ return (
16
+ registeredEvents[eventName].includes(tagName) ||
17
+ (!Object.values(registeredEvents)
18
+ .flat()
19
+ .includes(tagName) &&
20
+ registeredEvents[eventName].includes('*'))
21
+ )
22
+ }
23
+
24
+ function logRegisteredEvents () {
25
+ console.log(registeredEvents)
26
+ }
27
+
28
+ export {
29
+ registerEventListener,
30
+ registerEvent,
31
+ registeredEvents,
32
+ isRegisteredEvent,
33
+ logRegisteredEvents
34
+ }
@@ -0,0 +1,28 @@
1
+ import LifecycleEvents from './lifecycle_events'
2
+ const frameSources = {}
3
+
4
+ // fires after receiving a turbo HTTP response
5
+ addEventListener('turbo:before-fetch-response', event => {
6
+ const frame = event.target
7
+ frameSources[frame.id] = frame.src
8
+
9
+ const { turboReflexActive, turboReflexElementId } = frame.dataset
10
+ if (!turboReflexActive) return
11
+
12
+ const element = document.getElementById(turboReflexElementId)
13
+ delete frame.dataset.turboReflexActive
14
+ delete frame.dataset.turboReflexElementId
15
+
16
+ LifecycleEvents.dispatch(LifecycleEvents.finish, element || document, {
17
+ frame,
18
+ element: element || 'Unknown! Missing id attribute.'
19
+ })
20
+ })
21
+
22
+ // fires when a frame element is navigated and finishes loading
23
+ addEventListener('turbo:frame-load', event => {
24
+ const frame = event.target
25
+ frame.dataset.turboReflexSrc =
26
+ frameSources[frame.id] || frame.src || frame.dataset.turboReflexSrc
27
+ delete frameSources[frame.id]
28
+ })
@@ -0,0 +1,24 @@
1
+ const events = {
2
+ beforeStart: 'turbo-reflex:before-start',
3
+ start: 'turbo-reflex:start',
4
+ finish: 'turbo-reflex:finish',
5
+ error: 'turbo-reflex:error',
6
+ missingFrameId: 'turbo-reflex:missing-frame-id',
7
+ missingFrame: 'turbo-reflex:missing-frame',
8
+ missingFrameSrc: 'turbo-reflex:missing-frame-src'
9
+ }
10
+
11
+ function dispatch (name, target = document, detail = {}) {
12
+ const event = new CustomEvent(name, {
13
+ detail,
14
+ cancelable: true,
15
+ bubbles: true
16
+ })
17
+ target.dispatchEvent(event)
18
+ }
19
+
20
+ function logEventNames () {
21
+ Object.values(events).forEach(name => console.log(name))
22
+ }
23
+
24
+ export default { ...events, dispatch, logEventNames }
@@ -0,0 +1,7 @@
1
+ const Security = {
2
+ get token () {
3
+ return document.getElementById('turbo-reflex-token').getAttribute('content')
4
+ }
5
+ }
6
+
7
+ export default Security
@@ -0,0 +1,111 @@
1
+ import './frame_sources'
2
+ import Security from './security'
3
+ import LifecycleEvents from './lifecycle_events'
4
+ import {
5
+ findClosestReflex,
6
+ findClosestFrame,
7
+ findFrameId,
8
+ findFrame,
9
+ findFrameSrc,
10
+ buildAttributePayload
11
+ } from './elements'
12
+ import {
13
+ registerEventListener,
14
+ registerEvent,
15
+ registeredEvents,
16
+ isRegisteredEvent,
17
+ logRegisteredEvents
18
+ } from './event_registry'
19
+
20
+ // fires before making a turbo HTTP request
21
+ addEventListener('turbo:before-fetch-request', event => {
22
+ const frame = event.target
23
+ const { turboReflexActive } = frame.dataset
24
+ if (!turboReflexActive) return
25
+ const { fetchOptions } = event.detail
26
+ fetchOptions.headers['Turbo-Reflex'] = Security.token
27
+ })
28
+
29
+ function buildURL (urlString) {
30
+ const a = document.createElement('a')
31
+ a.href = urlString
32
+ return new URL(a)
33
+ }
34
+
35
+ function invokeFormReflex (form, payload = {}) {
36
+ payload.token = Security.token
37
+ const input = document.createElement('input')
38
+ input.type = 'hidden'
39
+ input.name = 'turbo_reflex'
40
+ input.value = JSON.stringify(payload)
41
+ form.appendChild(input)
42
+ }
43
+
44
+ function invokeReflex (event) {
45
+ let element, frameId, frame, frameSrc
46
+ try {
47
+ element = findClosestReflex(event.target)
48
+ if (!element) return
49
+
50
+ if (!isRegisteredEvent(event.type, element.tagName)) return
51
+
52
+ LifecycleEvents.dispatch(LifecycleEvents.beforeStart, element, { element })
53
+
54
+ frameId = findFrameId(element)
55
+ if (!frameId) return
56
+
57
+ frame = findFrame(frameId)
58
+ if (!frame) return
59
+
60
+ frameSrc = findFrameSrc(frame)
61
+ if (!frameSrc) return
62
+
63
+ const payload = {
64
+ frameId: frameId,
65
+ element: buildAttributePayload(element)
66
+ }
67
+
68
+ LifecycleEvents.dispatch(LifecycleEvents.start, element, {
69
+ element,
70
+ frameId,
71
+ frame,
72
+ frameSrc,
73
+ payload
74
+ })
75
+ frame.dataset.turboReflexActive = true
76
+ frame.dataset.turboReflexElementId = element.id
77
+
78
+ if (element.tagName.toLowerCase() === 'form')
79
+ return invokeFormReflex(element, payload)
80
+
81
+ event.preventDefault()
82
+ const frameURL = buildURL(frameSrc)
83
+ frameURL.searchParams.set('turbo_reflex', JSON.stringify(payload))
84
+ frame.src = frameURL.toString()
85
+ } catch (error) {
86
+ console.error(
87
+ `TurboReflex encountered an unexpected error!`,
88
+ { element, frameId, frame, frameSrc, target: event.target },
89
+ error
90
+ )
91
+ LifecycleEvents.dispatch(LifecycleEvents.error, element || document, {
92
+ element,
93
+ frameId,
94
+ frame,
95
+ frameSrc,
96
+ error
97
+ })
98
+ }
99
+ }
100
+
101
+ // wire things up and setup default events
102
+ registerEventListener(invokeReflex)
103
+ registerEvent('change', ['input', 'select', 'textarea'])
104
+ registerEvent('submit', ['form'])
105
+ registerEvent('click', ['*'])
106
+
107
+ export default {
108
+ registerEvent,
109
+ logRegisteredEvents,
110
+ logLifecycleEventNames: LifecycleEvents.logEventNames
111
+ }
data/bin/loc CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/bin/bash
2
2
 
3
- cloc --exclude-dir=Gemfile,Dockerfile,bin,builds,db,docs,log,node_modules,Procfile,public,storage,tmp --exclude-ext=example,json,lock,md,ru,toml,sql,svg,txt,yml "${1:-.}"
3
+ cloc --exclude-dir=assets app lib
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::Base
4
+ attr_reader :controller, :turbo_streams
5
+
6
+ def initialize(controller)
7
+ @controller = controller
8
+ @turbo_streams = Set.new
9
+ end
10
+
11
+ def params
12
+ controller.turbo_reflex_params
13
+ end
14
+
15
+ def element
16
+ @element ||= begin
17
+ keys = params[:element].keys.map { |key| key.to_s.parameterize.underscore.to_sym }
18
+ values = params[:element].values
19
+
20
+ unless keys.include? :value
21
+ keys << :value
22
+ values << nil
23
+ end
24
+
25
+ Struct.new(*keys).new(*values)
26
+ end
27
+ end
28
+
29
+ def turbo_stream
30
+ @turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
31
+ end
32
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "turbo-rails"
4
+ require_relative "version"
5
+ require_relative "sanitizer"
6
+ require_relative "base"
7
+
8
+ class TurboReflex::Engine < ::Rails::Engine
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::Sanitizer
4
+ include Singleton
5
+ include ActionView::Helpers::SanitizeHelper
6
+
7
+ attr_reader :scrubber
8
+
9
+ def sanitize(value)
10
+ super value, scrubber: scrubber
11
+ end
12
+
13
+ private
14
+
15
+ def initialize
16
+ @scrubber = Loofah::Scrubber.new do |node|
17
+ node.remove if node.name == "script"
18
+ end
19
+ end
20
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module TurboReflex
2
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
3
5
  end
data/lib/turbo_reflex.rb CHANGED
@@ -1,6 +1,3 @@
1
- require "turbo_reflex/version"
2
- require "turbo_reflex/railtie"
1
+ # frozen_string_literal: true
3
2
 
4
- module TurboReflex
5
- # Your code goes here...
6
- end
3
+ require "turbo_reflex/engine"
data/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turbo_reflex",
3
- "version": "0.0.0",
4
- "description": "Future home of TurboReflex",
3
+ "version": "0.0.1",
4
+ "description": "Reflexes for Turbo Frames that help you build robust reactive applications",
5
5
  "main": "app/assets/builds/turbo_reflex.js",
6
6
  "repository": "https://github.com/hopsoft/turbo_reflex",
7
7
  "author": "Nate Hopkins (hopsoft) <natehop@gmail.com>",
@@ -10,11 +10,11 @@
10
10
  "@hotwired/turbo-rails": ">= 7.1"
11
11
  },
12
12
  "devDependencies": {
13
- "esbuild": "^0.14.48",
13
+ "esbuild": "^0.15.7",
14
14
  "eslint": "^8.19.0",
15
15
  "prettier-standard": "^16.4.1"
16
16
  },
17
17
  "scripts": {
18
- "build": "esbuild app/javascript/*.* --bundle --minify --sourcemap --format=esm --outdir=app/assets/builds"
18
+ "build": "esbuild app/javascript/turbo_reflex.js --bundle --minify --sourcemap --format=esm --outfile=app/assets/builds/turbo_reflex.min.js"
19
19
  }
20
20
  }
data/turbo_reflex.gemspec CHANGED
@@ -2,27 +2,38 @@
2
2
 
3
3
  require_relative "lib/turbo_reflex/version"
4
4
 
5
- Gem::Specification.new do |spec|
6
- spec.name = "turbo_reflex"
7
- spec.version = TurboReflex::VERSION
8
- spec.authors = ["Nate Hopkins (hopsoft)"]
9
- spec.email = ["natehop@gmail.com"]
10
- spec.homepage = "https://github.com/hopsoft/turbo_reflex"
11
- spec.summary = "Future home of TurboReflex"
12
- spec.description = spec.summary
13
- spec.license = "MIT"
5
+ Gem::Specification.new do |s|
6
+ s.name = "turbo_reflex"
7
+ s.version = TurboReflex::VERSION
8
+ s.authors = ["Nate Hopkins (hopsoft)"]
9
+ s.email = ["natehop@gmail.com"]
10
+ s.homepage = "https://github.com/hopsoft/turbo_reflex"
11
+ s.summary = "Reflexes for Turbo Frames that help you build robust reactive applications"
12
+ s.description = "TurboReflex extends Turbo Frames and adds support for client triggered reflexes (think RPC) which let you sprinkle in functionality and skip the REST boilerplate."
13
+ s.license = "MIT"
14
14
 
15
- spec.metadata["homepage_uri"] = spec.homepage
16
- spec.metadata["source_code_uri"] = spec.homepage
17
- spec.metadata["changelog_uri"] = spec.homepage + "/blob/master/CHANGELOG.md"
15
+ s.metadata["homepage_uri"] = s.homepage
16
+ s.metadata["source_code_uri"] = s.homepage
17
+ s.metadata["changelog_uri"] = s.homepage + "/blob/master/CHANGELOG.md"
18
18
 
19
- spec.files = Dir["lib/**/*.rb", "app/**/*", "bin/*", "[A-Z]*"]
19
+ s.files = Dir["lib/**/*.rb", "app/**/*", "bin/*", "[A-Z]*"]
20
20
 
21
- spec.add_dependency "rails", ">= 7.0"
22
- spec.add_dependency "turbo-rails", ">= 1.1"
21
+ s.add_dependency "rails", ">= 6.1"
22
+ s.add_dependency "turbo-rails", ">= 1.1"
23
23
 
24
- spec.add_development_dependency "magic_frozen_string_literal"
25
- spec.add_development_dependency "pry-byebug"
26
- spec.add_development_dependency "standardrb"
27
- spec.add_development_dependency "tocer"
24
+ s.add_development_dependency "capybara"
25
+ s.add_development_dependency "cuprite"
26
+ s.add_development_dependency "importmap-rails"
27
+ s.add_development_dependency "magic_frozen_string_literal"
28
+ s.add_development_dependency "minitest-reporters"
29
+ s.add_development_dependency "net-smtp"
30
+ s.add_development_dependency "pry-byebug"
31
+ s.add_development_dependency "puma"
32
+ s.add_development_dependency "rake"
33
+ s.add_development_dependency "rexml"
34
+ s.add_development_dependency "sprockets-rails"
35
+ s.add_development_dependency "sqlite3"
36
+ s.add_development_dependency "standardrb"
37
+ s.add_development_dependency "turbo_ready"
38
+ s.add_development_dependency "webdrivers"
28
39
  end