turbo_reflex 0.0.1 → 0.0.2

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