turbo_boost-commands 0.2.2 → 0.3.0

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.
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.