turbo_boost-commands 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -29
  3. data/app/assets/builds/@turbo-boost/commands.js +1 -1
  4. data/app/assets/builds/@turbo-boost/commands.js.map +4 -4
  5. data/app/assets/builds/@turbo-boost/commands.metafile.json +1 -1
  6. data/app/controllers/concerns/turbo_boost/commands/controller.rb +1 -1
  7. data/app/javascript/elements.js +0 -1
  8. data/app/javascript/events.js +6 -3
  9. data/app/javascript/headers.js +2 -2
  10. data/app/javascript/index.js +20 -11
  11. data/app/javascript/invoker.js +2 -10
  12. data/app/javascript/lifecycle.js +3 -6
  13. data/app/javascript/logger.js +29 -2
  14. data/app/javascript/renderer.js +11 -5
  15. data/app/javascript/schema.js +2 -1
  16. data/app/javascript/state/index.js +47 -34
  17. data/app/javascript/state/observable.js +1 -1
  18. data/app/javascript/state/page.js +33 -0
  19. data/app/javascript/state/storage.js +11 -0
  20. data/app/javascript/turbo.js +0 -10
  21. data/app/javascript/version.js +1 -1
  22. data/lib/turbo_boost/commands/attribute_set.rb +8 -0
  23. data/lib/turbo_boost/commands/command.rb +8 -3
  24. data/lib/turbo_boost/commands/command_callbacks.rb +23 -6
  25. data/lib/turbo_boost/commands/command_validator.rb +44 -0
  26. data/lib/turbo_boost/commands/controller_pack.rb +10 -10
  27. data/lib/turbo_boost/commands/engine.rb +14 -10
  28. data/lib/turbo_boost/commands/errors.rb +15 -8
  29. data/lib/turbo_boost/commands/{middleware.rb → middlewares/entry_middleware.rb} +30 -21
  30. data/lib/turbo_boost/commands/middlewares/exit_middleware.rb +63 -0
  31. data/lib/turbo_boost/commands/patches/action_view_helpers_tag_helper_tag_builder_patch.rb +10 -2
  32. data/lib/turbo_boost/commands/responder.rb +28 -0
  33. data/lib/turbo_boost/commands/runner.rb +150 -186
  34. data/lib/turbo_boost/commands/sanitizer.rb +1 -1
  35. data/lib/turbo_boost/commands/state.rb +97 -47
  36. data/lib/turbo_boost/commands/state_store.rb +72 -0
  37. data/lib/turbo_boost/commands/token_validator.rb +51 -0
  38. data/lib/turbo_boost/commands/version.rb +1 -1
  39. metadata +29 -8
@@ -7,7 +7,7 @@ class TurboBoost::Commands::Sanitizer
7
7
  attr_reader :scrubber
8
8
 
9
9
  def sanitize(value)
10
- super(value, scrubber: scrubber)
10
+ super(value.to_s, scrubber: scrubber)
11
11
  end
12
12
 
13
13
  private
@@ -1,73 +1,123 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest/md5"
4
+ require_relative "state_store"
5
+
6
+ # Class that encapsulates all the various forms of state.
7
+ #
8
+ # 1. `page` - Client-side transient page state used for rendering remembered element attributes
9
+ # 2. `now` - Server-side state for the current render only (discarded after rendering)
10
+ # 3. `signed` - Server-side state that persists across renders (state that was used for the last server-side render)
11
+ # 4. `unsigned` - Client-side state (optimistic client-side changes)
12
+ # 5. `current` - Combined server-side state (signed + now)
13
+ # 6. `all` - All state except unsigned (signed + now + page)
14
+ #
3
15
  class TurboBoost::Commands::State
4
16
  include Enumerable
5
17
 
6
- class << self
7
- def from_sgid_param(sgid)
8
- new URI::UID.from_sgid(sgid, for: name)&.decode
9
- end
10
- end
18
+ def initialize(payload = {})
19
+ payload = payload.respond_to?(:to_unsafe_h) ? payload.to_unsafe_h : payload.to_h
20
+ payload = payload.with_indifferent_access
11
21
 
12
- def initialize(store = nil, provisional: false)
13
- @store = store || ActiveSupport::Cache::MemoryStore.new(expires_in: 1.day, size: 16.kilobytes)
14
- @store.cleanup
15
- @provisional = provisional
22
+ @now = {}.with_indifferent_access
23
+ @page = payload.fetch(:page, {}).with_indifferent_access
24
+ @signed = TurboBoost::Commands::StateStore.new(payload.fetch(:signed, {}))
25
+ @unsigned = payload.fetch(:unsigned, {}).with_indifferent_access
16
26
  end
17
27
 
18
- delegate :to_json, to: :to_h
19
- delegate_missing_to :store
20
-
21
- def dig(*keys)
22
- to_h.with_indifferent_access.dig(*keys)
28
+ # Client-side transient page state used for rendering remembered element attributes
29
+ # @return [HashWithIndifferentAccess]
30
+ attr_reader :page
31
+
32
+ # Server-side state for the current render only (similar to flash.now)
33
+ # @note Discarded after rendering
34
+ # @return [HashWithIndifferentAccess]
35
+ attr_reader :now
36
+
37
+ # Server-side state that persists across renders
38
+ # This is the state that was used for the last server-side render (untampered by the client)
39
+ # @return [TurboBoost::Commands::StateStore]
40
+ attr_reader :signed
41
+
42
+ # @note Most state will interactions work with the signed state, so we delegate missing methods to it.
43
+ delegate_missing_to :signed
44
+
45
+ # Client-side state (optimistic client-side changes)
46
+ # @note There is a hook on Command instances to resolve state `Command#resolve_state`,
47
+ # where Command authors can determine how to properly handle optimistic client-side state.
48
+ # @return [HashWithIndifferentAccess]
49
+ attr_reader :unsigned
50
+ alias_method :optimistic, :unsigned
51
+
52
+ # Combined server-side state (signed + now)
53
+ # @return [HashWithIndifferentAccess]
54
+ def current
55
+ signed.to_h.merge now
23
56
  end
24
57
 
25
- def merge!(hash = {})
26
- hash.to_h.each { |key, val| self[key] = val }
27
- self
28
- end
58
+ delegate :each, to: :current
29
59
 
30
- def each
31
- data.keys.each { |key| yield(key, self[key]) }
60
+ # All state except unsigned (page + current).
61
+ # @return [HashWithIndifferentAccess]
62
+ def all
63
+ page.merge current
32
64
  end
33
65
 
34
- # Provisional state is for the current request/response and is exposed as `State#now`
35
- # Standard state is preserved across multiple requests
36
- def provisional?
37
- !!@provisional
66
+ # Returns a cache key representing "all" state
67
+ def cache_key
68
+ "TurboBoost::Commands::State/#{Digest::MD5.base64digest(all.to_s)}"
38
69
  end
39
70
 
40
- def now
41
- return nil if provisional? # provisional state cannot hold child provisional state
42
- @now ||= self.class.new(provisional: true)
71
+ # A JSON representation of state that can be sent to the client
72
+ #
73
+ # Includes the following keys:
74
+ # * `signed` - The signed state (String)
75
+ # * `unsigned` - The unsigned state (Hash)
76
+ #
77
+ # @return [String]
78
+ def to_json
79
+ {signed: signed.to_sgid_param, unsigned: signed.to_h}.to_json(camelize: false)
43
80
  end
44
81
 
45
- def cache_key
46
- "TurboBoost::Commands::State/#{Digest::SHA2.base64digest(to_json)}"
47
- end
82
+ def tag_options(options = {})
83
+ return options unless options.is_a?(Hash)
48
84
 
49
- def read(...)
50
- now&.read(...) || store.read(...)
51
- end
85
+ options = options.deep_symbolize_keys
86
+ return options unless options.key?(:turbo_boost)
52
87
 
53
- def [](...)
54
- read(...)
55
- end
88
+ config = options.delete(:turbo_boost)
89
+ return options unless config.is_a?(Hash)
56
90
 
57
- def []=(...)
58
- write(...)
59
- end
91
+ attributes = config[:remember]
92
+ return options if attributes.blank?
60
93
 
61
- def to_sgid_param
62
- store.cleanup
63
- URI::UID.build(store, include_blank: false).to_sgid_param for: self.class.name, expires_in: 1.week
64
- end
94
+ attributes = begin
95
+ attributes.is_a?(Array) ? attributes : JSON.parse(attributes.to_s)
96
+ rescue
97
+ raise TurboBoost::Commands::StateError,
98
+ "Invalid `turbo_boost` options! `attributes` must be an Array of attributes to remember!"
99
+ end
100
+ attributes ||= []
101
+ attributes.map!(&:to_s).uniq!
102
+ return options if attributes.blank?
65
103
 
66
- private
104
+ if options[:id].blank?
105
+ raise TurboBoost::Commands::StateError, "An `id` attribute is required for remembering state!"
106
+ end
67
107
 
68
- attr_reader :store
108
+ options[:aria] ||= {}
109
+ options[:data] ||= {}
110
+ options[:data][:turbo_boost_state_attributes] = attributes.to_json
111
+
112
+ attributes.each do |name|
113
+ value = page.dig(options[:id], name)
114
+ case name
115
+ in String if name.start_with?("aria-") then options[:aria][name.delete_prefix("aria-").to_sym] = value
116
+ in String if name.start_with?("data-") then options[:data][name.delete_prefix("data-").to_sym] = value
117
+ else options[name.to_sym] = value
118
+ end
119
+ end
69
120
 
70
- def data
71
- store.instance_variable_get(:@data) || {}
121
+ options
72
122
  end
73
123
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboBoost::Commands::StateStore < ActiveSupport::Cache::MemoryStore
4
+ include Enumerable
5
+
6
+ class NoCoder
7
+ def dump(value)
8
+ value
9
+ end
10
+
11
+ def load(value)
12
+ value
13
+ end
14
+ end
15
+
16
+ SGID_PURPOSE = name.dup.freeze
17
+
18
+ def initialize(payload = {})
19
+ super(coder: NoCoder.new, compress: false, expires_in: 1.day, size: 16.kilobytes)
20
+
21
+ begin
22
+ payload = case payload
23
+ when SignedGlobalID then URI::UID.from_sgid(payload, for: SGID_PURPOSE)&.decode
24
+ when GlobalID then URI::UID.from_gid(payload)&.decode
25
+ when String then URI::UID.from_sgid(payload, for: SGID_PURPOSE)&.decode || URI::UID.from_gid(payload, for: SGID_PURPOSE)&.decode
26
+ else payload
27
+ end
28
+ rescue => error
29
+ Rails.logger.error "Failed to decode URI::UID when creating a TurboBoost::Commands::StateStore! #{error.message}"
30
+ payload = {}
31
+ end
32
+
33
+ merge! payload
34
+ end
35
+
36
+ alias_method :[], :read
37
+ alias_method :[]=, :write
38
+
39
+ def to_h
40
+ @data
41
+ .each_with_object({}) { |(key, entry), memo| memo[key] = entry.value }
42
+ .with_indifferent_access
43
+ end
44
+
45
+ delegate :dig, :each, to: :to_h
46
+
47
+ def merge!(other = {})
48
+ other.to_h.each { |key, val| write key, val }
49
+ self
50
+ end
51
+
52
+ def to_uid
53
+ cleanup
54
+ URI::UID.build to_h, include_blank: false
55
+ end
56
+
57
+ def to_gid
58
+ to_uid.to_gid
59
+ end
60
+
61
+ def to_gid_param
62
+ to_gid.to_param
63
+ end
64
+
65
+ def to_sgid
66
+ to_uid.to_sgid for: SGID_PURPOSE, expires_in: 1.day
67
+ end
68
+
69
+ def to_sgid_param
70
+ to_sgid.to_param
71
+ end
72
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboBoost::Commands::TokenValidator
4
+ def initialize(command, method_name)
5
+ @command = command
6
+ @method_name = method_name
7
+ end
8
+
9
+ attr_reader :command, :method_name
10
+ delegate :controller, to: :command
11
+ delegate :session, to: :controller
12
+
13
+ def validate
14
+ return true unless TurboBoost::Commands.config.protect_from_forgery
15
+ tokens.any? { |token| valid_token? token }
16
+ end
17
+
18
+ alias_method :valid?, :validate
19
+
20
+ def validate!
21
+ return true if valid?
22
+
23
+ message = <<~MSG
24
+ `#{command.class.name}##{method_name}` invoked with an invalid authenticity token!
25
+
26
+ Verify that your page includes `<%= csrf_meta_tags %>` in the header.
27
+
28
+ If the problem persists, you can disable forgery protection with `TurboBoost::Commands.config.protect_from_forgery = false`
29
+ MSG
30
+
31
+ raise TurboBoost::Commands::InvalidTokenError.new(message, command: command.class)
32
+ end
33
+
34
+ private
35
+
36
+ def tokens
37
+ list = Set.new.tap do |set|
38
+ set.add command.params[:csrf_token]
39
+
40
+ # TODO: Update to use Rails' public API
41
+ set.merge controller.send(:request_authenticity_tokens)
42
+ end
43
+
44
+ list.select(&:present?).to_a
45
+ end
46
+
47
+ def valid_token?(token)
48
+ # TODO: Update to use Rails' public API
49
+ controller.send :valid_authenticity_token?, session, token
50
+ end
51
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TurboBoost
4
4
  module Commands
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_boost-commands
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nate Hopkins (hopsoft)
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-14 00:00:00.000000000 Z
11
+ date: 2024-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: 0.1.7
69
+ - !ruby/object:Gem::Dependency
70
+ name: amazing_print
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: capybara
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -336,16 +350,16 @@ dependencies:
336
350
  name: sqlite3
337
351
  requirement: !ruby/object:Gem::Requirement
338
352
  requirements:
339
- - - ">="
353
+ - - "~>"
340
354
  - !ruby/object:Gem::Version
341
- version: '0'
355
+ version: '1.7'
342
356
  type: :development
343
357
  prerelease: false
344
358
  version_requirements: !ruby/object:Gem::Requirement
345
359
  requirements:
346
- - - ">="
360
+ - - "~>"
347
361
  - !ruby/object:Gem::Version
348
- version: '0'
362
+ version: '1.7'
349
363
  - !ruby/object:Gem::Dependency
350
364
  name: standardrb
351
365
  requirement: !ruby/object:Gem::Requirement
@@ -431,6 +445,8 @@ files:
431
445
  - app/javascript/schema.js
432
446
  - app/javascript/state/index.js
433
447
  - app/javascript/state/observable.js
448
+ - app/javascript/state/page.js
449
+ - app/javascript/state/storage.js
434
450
  - app/javascript/turbo.js
435
451
  - app/javascript/urls.js
436
452
  - app/javascript/uuids.js
@@ -440,16 +456,21 @@ files:
440
456
  - lib/turbo_boost/commands/attribute_set.rb
441
457
  - lib/turbo_boost/commands/command.rb
442
458
  - lib/turbo_boost/commands/command_callbacks.rb
459
+ - lib/turbo_boost/commands/command_validator.rb
443
460
  - lib/turbo_boost/commands/controller_pack.rb
444
461
  - lib/turbo_boost/commands/engine.rb
445
462
  - lib/turbo_boost/commands/errors.rb
446
463
  - lib/turbo_boost/commands/http_status_codes.rb
447
- - lib/turbo_boost/commands/middleware.rb
464
+ - lib/turbo_boost/commands/middlewares/entry_middleware.rb
465
+ - lib/turbo_boost/commands/middlewares/exit_middleware.rb
448
466
  - lib/turbo_boost/commands/patches.rb
449
467
  - lib/turbo_boost/commands/patches/action_view_helpers_tag_helper_tag_builder_patch.rb
468
+ - lib/turbo_boost/commands/responder.rb
450
469
  - lib/turbo_boost/commands/runner.rb
451
470
  - lib/turbo_boost/commands/sanitizer.rb
452
471
  - lib/turbo_boost/commands/state.rb
472
+ - lib/turbo_boost/commands/state_store.rb
473
+ - lib/turbo_boost/commands/token_validator.rb
453
474
  - lib/turbo_boost/commands/version.rb
454
475
  homepage: https://github.com/hopsoft/turbo_boost-commands
455
476
  licenses:
@@ -473,7 +494,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
473
494
  - !ruby/object:Gem::Version
474
495
  version: '0'
475
496
  requirements: []
476
- rubygems_version: 3.5.6
497
+ rubygems_version: 3.5.10
477
498
  signing_key:
478
499
  specification_version: 4
479
500
  summary: Commands to help you build robust reactive applications with Rails & Hotwire.