turbo_boost-commands 0.1.2 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of turbo_boost-commands might be problematic. Click here for more details.

@@ -0,0 +1,39 @@
1
+ import headers from './headers'
2
+ import lifecycle from './lifecycle'
3
+ import state from './state'
4
+ import urls from './urls'
5
+ import { dispatch } from './events'
6
+ import { render } from './renderer'
7
+
8
+ const parseError = error => {
9
+ const errorMessage = `Unexpected error performing a TurboBoost Command! ${error.message}`
10
+ dispatch(lifecycle.events.clientError, document, { detail: { error: errorMessage } }, true)
11
+ }
12
+
13
+ const parseAndRenderResponse = response => {
14
+ const { strategy } = headers.tokenize(response.headers.get(headers.RESPONSE_HEADER))
15
+
16
+ // FAIL: Status outside the range of 200-399
17
+ if (response.status < 200 || response.status > 399) {
18
+ const error = `Server returned a ${response.status} status code! TurboBoost Commands require 2XX-3XX status codes.`
19
+ dispatch(lifecycle.events.serverError, document, { detail: { error, response } }, true)
20
+ }
21
+
22
+ response.text().then(content => render(strategy, content))
23
+ }
24
+
25
+ const invoke = (payload = {}) => {
26
+ try {
27
+ fetch(urls.commandInvocationURL.href, {
28
+ method: 'POST',
29
+ headers: headers.prepare({}),
30
+ body: JSON.stringify(payload)
31
+ })
32
+ .then(parseAndRenderResponse)
33
+ .catch(parseError)
34
+ } catch (error) {
35
+ parseError(error)
36
+ }
37
+ }
38
+
39
+ export { invoke }
@@ -1,6 +1,8 @@
1
1
  import { allEvents as events } from './events'
2
2
 
3
3
  let currentLevel = 'unknown'
4
+ let initialized = false
5
+ let history = []
4
6
 
5
7
  const logLevels = {
6
8
  debug: Object.values(events),
@@ -10,14 +12,33 @@ const logLevels = {
10
12
  unknown: []
11
13
  }
12
14
 
13
- Object.values(events).forEach(name => {
14
- addEventListener(name, event => {
15
- if (logLevels[currentLevel].includes(event.type)) {
16
- const { target, detail } = event
17
- console[currentLevel](event.type, { target, detail })
18
- }
19
- })
20
- })
15
+ const shouldLogEvent = event => {
16
+ if (!logLevels[currentLevel].includes(event.type)) return false
17
+ if (typeof console[currentLevel] !== 'function') return false
18
+
19
+ const { detail } = event
20
+ if (!detail.id) return true
21
+
22
+ const key = `${event.type}-${detail.id}`
23
+ if (history.includes(key)) return false
24
+
25
+ if (history.length > 16) history.shift()
26
+ history.push(key)
27
+
28
+ return true
29
+ }
30
+
31
+ const logEvent = event => {
32
+ if (shouldLogEvent(event)) {
33
+ const { target, type, detail } = event
34
+ console[currentLevel](type, detail.id || '', { target, detail })
35
+ }
36
+ }
37
+
38
+ if (!initialized) {
39
+ initialized = true
40
+ Object.values(events).forEach(name => addEventListener(name, event => logEvent(event)))
41
+ }
21
42
 
22
43
  export default {
23
44
  get level() {
@@ -1,16 +1,18 @@
1
- function replaceDocument(content) {
2
- const head = '<html'
3
- const tail = '</html'
4
- const headIndex = content.indexOf(head)
5
- const tailIndex = content.lastIndexOf(tail)
6
- if (headIndex >= 0 && tailIndex >= 0) {
7
- const html = content.slice(content.indexOf('>', headIndex) + 1, tailIndex)
8
- document.documentElement.innerHTML = html
9
- }
10
- }
1
+ import uuids from './uuids'
11
2
 
12
- function append(content) {
3
+ const append = content => {
13
4
  document.body.insertAdjacentHTML('beforeend', content)
14
5
  }
15
6
 
16
- export default { append, replaceDocument }
7
+ const replace = content => {
8
+ const parser = new DOMParser()
9
+ const doc = parser.parseFromString(content, 'text/html')
10
+ TurboBoost.Streams.morph.method(document.documentElement, doc.documentElement)
11
+ }
12
+
13
+ export const render = (strategy, content) => {
14
+ if (strategy.match(/^Append$/i)) return append(content)
15
+ if (strategy.match(/^Replace$/i)) return replace(content)
16
+ }
17
+
18
+ export default { render }
@@ -1,46 +1,37 @@
1
- import state from './state'
2
- import renderer from './renderer'
3
- import { dispatch } from './events'
1
+ import headers from './headers'
4
2
  import lifecycle from './lifecycle'
3
+ import { dispatch } from './events'
4
+ import { render } from './renderer'
5
5
 
6
6
  const frameSources = {}
7
7
 
8
- // fires before making a turbo HTTP request
9
- addEventListener('turbo:before-fetch-request', event => {
10
- const frame = event.target.closest('turbo-frame')
11
- const { fetchOptions } = event.detail
12
-
13
- // command invoked and busy
14
- if (self.TurboBoost?.Commands?.busy) {
15
- let acceptHeaders = ['text/vnd.turbo-boost.html', fetchOptions.headers['Accept']]
16
- acceptHeaders = acceptHeaders.filter(entry => entry && entry.trim().length > 0).join(', ')
17
- fetchOptions.headers['Accept'] = acceptHeaders
18
- }
19
- })
20
-
21
8
  // fires after receiving a turbo HTTP response
22
9
  addEventListener('turbo:before-fetch-response', event => {
23
10
  const frame = event.target.closest('turbo-frame')
11
+ if (frame?.id && frame?.src) frameSources[frame.id] = frame.src
12
+
24
13
  const { fetchResponse: response } = event.detail
14
+ const header = response.header(headers.RESPONSE_HEADER)
25
15
 
26
- if (frame) frameSources[frame.id] = frame.src
16
+ if (!header) return
27
17
 
28
- if (response.header('TurboBoost')) {
29
- if (response.statusCode < 200 || response.statusCode > 399) {
30
- const error = `Server returned a ${response.statusCode} status code! TurboBoost Commands require 2XX-3XX status codes.`
31
- dispatch(lifecycle.events.clientError, document, { detail: { ...event.detail, error } }, true)
32
- }
18
+ // We'll take it from here Hotwire...
19
+ event.preventDefault()
20
+ const { statusCode } = response
21
+ const { strategy } = headers.tokenize(header)
33
22
 
34
- if (response.header('TurboBoost') === 'Append') {
35
- event.preventDefault()
36
- response.responseText.then(content => renderer.append(content))
37
- }
23
+ // FAIL: Status outside the range of 200-399
24
+ if (statusCode < 200 || statusCode > 399) {
25
+ const error = `Server returned a ${status} status code! TurboBoost Commands require 2XX-3XX status codes.`
26
+ dispatch(lifecycle.events.clientError, document, { detail: { error, response } }, true)
38
27
  }
28
+
29
+ response.responseHTML.then(content => render(strategy, content))
39
30
  })
40
31
 
41
32
  // fires when a frame element is navigated and finishes loading
42
33
  addEventListener('turbo:frame-load', event => {
43
34
  const frame = event.target.closest('turbo-frame')
44
- frame.dataset.turboBoostSrc = frameSources[frame.id] || frame.src || frame.dataset.turboBoostSrc
35
+ frame.dataset.src = frameSources[frame.id] || frame.src || frame.dataset.src
45
36
  delete frameSources[frame.id]
46
37
  })
@@ -1,9 +1,11 @@
1
- function build(urlString, payload = {}) {
1
+ const buildURL = path => {
2
2
  const a = document.createElement('a')
3
- a.href = urlString
4
- const url = new URL(a)
5
- url.searchParams.set('tbc', JSON.stringify(payload))
6
- return url
3
+ a.href = path
4
+ return new URL(a)
7
5
  }
8
6
 
9
- export default { build }
7
+ export default {
8
+ get commandInvocationURL() {
9
+ return buildURL('/turbo-boost-command-invocation')
10
+ }
11
+ }
@@ -1 +1 @@
1
- export default '0.1.2'
1
+ export default '0.2.1'
@@ -90,7 +90,11 @@ class TurboBoost::Commands::Command
90
90
  @turbo_streams = Set.new
91
91
  end
92
92
 
93
- # Abstract `perform` method, overridde in subclassed commands
93
+ # Abstract method to resolve state (default noop), override in subclassed commands
94
+ def resolve_state(client_state)
95
+ end
96
+
97
+ # Abstract `perform` method, override in subclassed commands
94
98
  def perform
95
99
  raise NotImplementedError, "#{self.class.name} must implement the `perform` method!"
96
100
  end
@@ -113,6 +117,7 @@ class TurboBoost::Commands::Command
113
117
  end
114
118
 
115
119
  # Same method signature as ActionView::Rendering#render (i.e. controller.view_context.render)
120
+ # Great for rendering partials with short-hand syntax sugar → `render "/path/to/partial"`
116
121
  def render(options = {}, locals = {}, &block)
117
122
  return controller.view_context.render(options, locals, &block) unless options.is_a?(Hash)
118
123
 
@@ -8,6 +8,7 @@ class TurboBoost::Commands::ControllerPack
8
8
  attr_reader :runner, :command
9
9
 
10
10
  delegate(
11
+ :command_state,
11
12
  :command_aborted?,
12
13
  :command_errored?,
13
14
  :command_performed?,
@@ -15,7 +16,6 @@ class TurboBoost::Commands::ControllerPack
15
16
  :command_requested?,
16
17
  :command_succeeded?,
17
18
  :controller,
18
- :state,
19
19
  to: :runner
20
20
  )
21
21
 
@@ -23,4 +23,9 @@ class TurboBoost::Commands::ControllerPack
23
23
  @runner = TurboBoost::Commands::Runner.new(controller)
24
24
  @command = runner.command_instance
25
25
  end
26
+
27
+ def state
28
+ ActiveSupport::Deprecation.warn "The `state` method has been deprecated. Please update to `command_state`."
29
+ command_state
30
+ end
26
31
  end
@@ -7,6 +7,7 @@ require_relative "version"
7
7
  require_relative "http_status_codes"
8
8
  require_relative "errors"
9
9
  require_relative "patches"
10
+ require_relative "middleware"
10
11
  require_relative "command"
11
12
  require_relative "controller_pack"
12
13
  require_relative "../../../app/controllers/concerns/turbo_boost/commands/controller"
@@ -19,16 +20,16 @@ module TurboBoost::Commands
19
20
 
20
21
  class Engine < ::Rails::Engine
21
22
  config.turbo_boost_commands = ActiveSupport::OrderedOptions.new
22
- config.turbo_boost_commands[:validate_client_token] = false
23
+ config.turbo_boost_commands[:protect_from_forgery] = true
24
+ config.turbo_boost_commands[:precompile_assets] = true
23
25
 
24
26
  # must opt-in to state overrides
25
27
  config.turbo_boost_commands[:apply_client_state_overrides] = false
26
28
  config.turbo_boost_commands[:apply_server_state_overrides] = false
27
29
 
28
- config.turbo_boost_commands.precompile_assets = true
29
-
30
- initializer "turbo_boost_commands.configuration" do
30
+ initializer "turbo_boost_commands.configuration" do |app|
31
31
  Mime::Type.register "text/vnd.turbo-boost.html", :turbo_boost
32
+ app.middleware.insert 0, TurboBoost::Commands::Middleware
32
33
 
33
34
  ActiveSupport.on_load :action_controller_base do
34
35
  # `self` is ActionController::Base
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboBoost::Commands::Middleware
4
+ PATH = "/turbo-boost-command-invocation"
5
+
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ request = Rack::Request.new(env)
12
+ modify! request if modify?(request)
13
+ @app.call env
14
+ end
15
+
16
+ private
17
+
18
+ # Returns the MIME type for TurboBoost Command invocations.
19
+ def mime_type
20
+ Mime::Type.lookup_by_extension(:turbo_boost)
21
+ end
22
+
23
+ # Indicates whether or not the request is a TurboBoost Command invocation that requires modifications
24
+ # before we hand things over to Rails.
25
+ #
26
+ # @param request [Rack::Request] the request to check
27
+ # @return [Boolean] true if the request is a TurboBoost Command invocation, false otherwise
28
+ def modify?(request)
29
+ return false unless request.post?
30
+ return false unless request.path.start_with?(PATH)
31
+ return false unless mime_type && request.env["HTTP_ACCEPT"]&.include?(mime_type)
32
+ true
33
+ rescue => error
34
+ puts "#{self.class.name} failed to determine if the request should be modified! #{error.message}"
35
+ false
36
+ end
37
+
38
+ # Modifies the given POST request so Rails sees it as GET.
39
+ #
40
+ # The posted JSON body content holds the TurboBoost Command meta data.
41
+ # The parsed JSON body is stored in the environment under the `turbo_boost_command` key.
42
+ #
43
+ # @example POST payload for: /turbo-boost-command-invocation
44
+ # {
45
+ # "id" => "turbo-command-f824ded1-a86e-4a36-9442-ea2165a64569", # unique command invocation id
46
+ # "name" => "IncrementCountCommand", # the command being invoked
47
+ # "elementId" => nil, # the triggering element's dom id
48
+ # "elementAttributes" => {"tag"=>"BUTTON", "checked"=>false, "disabled"=>false, "value"=>nil}, # the triggering element's attributes
49
+ # "startedAt" => 1708213193567, # the time the command was invoked
50
+ # "changedState" => {}, # the delta of optimistic state changes made on the client
51
+ # "clientState" => { # the state as it was on the client
52
+ # "command_token" => "IlU0dVVhNElFdkVCZVUi--a878d33d85ed9b9611c155ed1d7bb8785fb..."} # the command token used for forgery protection
53
+ # },
54
+ # "signedState" => "eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2R1VuaXZlcnNhbElEOjpFeH...", # the state as it was on the server at the time of the last command invocation
55
+ # "driver" => "frame", # the driver used to invoke the command
56
+ # "frameId" => "basic_command-turbo-frame", # the turbo-frame id (if applicable)
57
+ # "src" => "/basic_command.turbo_stream" # the URL to present to Rails (turbo-frame src, window location, etc.)
58
+ # }
59
+ #
60
+ # @param request [Rack::Request] the request to modify
61
+ def modify!(request)
62
+ params = JSON.parse(request.body.string)
63
+ uri = URI.parse(params["src"])
64
+
65
+ request.env.tap do |env|
66
+ # Store the command params in the environment
67
+ env["turbo_boost_command"] = params
68
+
69
+ # Change the method from POST to GET
70
+ env["REQUEST_METHOD"] = "GET"
71
+
72
+ # Update the URI, PATH_INFO, and QUERY_STRING
73
+ env["REQUEST_URI"] = uri.to_s if env.key?("REQUEST_URI")
74
+ env["PATH_INFO"] = uri.path
75
+ env["QUERY_STRING"] = uri.query.to_s
76
+
77
+ # Clear the body and related headers so the appears and behaves like a GET
78
+ env["rack.input"] = StringIO.new
79
+ env["CONTENT_LENGTH"] = "0"
80
+ env.delete("CONTENT_TYPE")
81
+ end
82
+ rescue => error
83
+ puts "#{self.class.name} failed to modify the request! #{error.message}"
84
+ end
85
+ end
@@ -4,6 +4,8 @@ require_relative "sanitizer"
4
4
  require_relative "state"
5
5
 
6
6
  class TurboBoost::Commands::Runner
7
+ RESPONSE_HEADER = "TurboBoost-Command"
8
+
7
9
  SUPPORTED_MEDIA_TYPES = {
8
10
  "text/html" => true,
9
11
  "text/vnd.turbo-boost.html" => true,
@@ -16,8 +18,8 @@ class TurboBoost::Commands::Runner
16
18
  @controller = controller
17
19
  end
18
20
 
19
- def state
20
- @state ||= begin
21
+ def command_state
22
+ @command_state ||= begin
21
23
  sgid = command_params[:signed_state]
22
24
  value = TurboBoost::Commands::State.from_sgid_param(sgid) if sgid
23
25
  value || TurboBoost::Commands::State.new
@@ -25,7 +27,7 @@ class TurboBoost::Commands::Runner
25
27
  end
26
28
 
27
29
  def command_requested?
28
- command_params.present?
30
+ controller.request.env.key?("turbo_boost_command") || controller.params.key?("turbo_boost_command")
29
31
  end
30
32
 
31
33
  def command_valid?
@@ -44,18 +46,19 @@ class TurboBoost::Commands::Runner
44
46
  end
45
47
 
46
48
  # validate csrf token
47
- unless valid_client_token?
49
+ unless valid_command_token?
48
50
  raise TurboBoost::Commands::InvalidTokenError,
49
- "Token mismatch! The token: #{client_token}` does not match the expected value of `#{server_token}`."
51
+ "Token mismatch! The token: #{client_command_token}` does not match the expected value of `#{server_command_token}`."
50
52
  end
51
53
 
52
54
  true
53
55
  end
54
56
 
55
57
  def command_params
56
- return ActionController::Parameters.new if controller.params.keys.none?(/\A(tbc|turbo_boost_command)\z/o)
58
+ return ActionController::Parameters.new unless command_requested?
57
59
  @command_params ||= begin
58
- payload = parsed_command_params.deep_transform_keys(&:underscore)
60
+ payload = parsed_command_params.transform_keys(&:underscore)
61
+ payload["element_attributes"]&.deep_transform_keys!(&:underscore)
59
62
  ActionController::Parameters.new(payload).permit!
60
63
  end
61
64
  end
@@ -83,7 +86,7 @@ class TurboBoost::Commands::Runner
83
86
  end
84
87
 
85
88
  def command_instance
86
- @command_instance ||= command_class&.new(controller, state, command_params).tap do |instance|
89
+ @command_instance ||= command_class&.new(controller, command_state, command_params).tap do |instance|
87
90
  instance&.add_observer self, :handle_command_event
88
91
  end
89
92
  end
@@ -108,6 +111,10 @@ class TurboBoost::Commands::Runner
108
111
  !!command_instance&.succeeded?
109
112
  end
110
113
 
114
+ def controller_action_allowed?
115
+ !controller_action_prevented?
116
+ end
117
+
111
118
  def controller_action_prevented?
112
119
  !!@controller_action_prevented
113
120
  end
@@ -123,7 +130,8 @@ class TurboBoost::Commands::Runner
123
130
  return if command_errored?
124
131
  return if command_performing?
125
132
  return if command_performed?
126
- state.resolve command_params[:client_state]
133
+
134
+ command_instance.resolve_state command_params[:changed_state]
127
135
  command_instance.perform_with_callbacks command_method_name
128
136
  end
129
137
 
@@ -136,17 +144,16 @@ class TurboBoost::Commands::Runner
136
144
  render_response status: response_status
137
145
  append_success_to_response
138
146
  when TurboBoost::Commands::AbortError
139
- render_response status: error.http_status_code, headers: {"TurboBoost-Command-Status": error.message}
147
+ render_response status: error.http_status_code, status_header: error.message
140
148
  append_streams_to_response_body
141
149
  when TurboBoost::Commands::PerformError
142
- render_response status: error.http_status_code, headers: {"TurboBoost-Command-Status": error.message}
150
+ render_response status: error.http_status_code, status_header: error.message
143
151
  append_error_to_response error
144
152
  else
145
- render_response status: :internal_server_error, headers: {"TurboBoost-Command-Status": error.message}
153
+ render_response status: :internal_server_error, status_header: error.message
146
154
  append_error_to_response error
147
155
  end
148
156
 
149
- append_command_token_to_response_body
150
157
  append_command_state_to_response_body
151
158
  end
152
159
 
@@ -156,17 +163,16 @@ class TurboBoost::Commands::Runner
156
163
 
157
164
  return if controller_action_prevented?
158
165
 
159
- append_to_response_headers
160
- append_command_token_to_response_body
161
166
  append_command_state_to_response_body
167
+ append_to_response_headers if command_performed?
162
168
  append_success_to_response if command_succeeded?
163
169
  rescue => error
164
170
  Rails.logger.error "TurboBoost::Commands::Runner failed to update the response! #{error.message}"
165
171
  end
166
172
 
167
- def render_response(html: "", status: nil, headers: {})
168
- controller.render html: html, layout: false, status: status || response_status
169
- append_to_response_headers headers.merge(TurboBoost: :Append)
173
+ def render_response(html: "", status: nil, status_header: nil)
174
+ controller.render html: html, layout: false, status: status || response_status # unless controller.performed?
175
+ append_to_response_headers status_header
170
176
  end
171
177
 
172
178
  def turbo_stream
@@ -191,40 +197,40 @@ class TurboBoost::Commands::Runner
191
197
  private
192
198
 
193
199
  def parsed_command_params
194
- @parsed_command_params ||= JSON.parse(controller.params[:tbc] || controller.params[:turbo_boost_command])
200
+ @parsed_command_params ||= begin
201
+ params = controller.request.env["turbo_boost_command"]
202
+ params ||= JSON.parse(controller.params["turbo_boost_command"])
203
+ params || {}
204
+ end
195
205
  end
196
206
 
197
207
  def content_sanitizer
198
208
  TurboBoost::Commands::Sanitizer.instance
199
209
  end
200
210
 
201
- # TODO: revisit command token validation
202
- def new_token
203
- @new_token ||= SecureRandom.alphanumeric(13)
211
+ def new_command_token
212
+ @new_command_token ||= SecureRandom.alphanumeric(13)
204
213
  end
205
214
 
206
- # TODO: revisit command token validation
207
- def server_token
208
- nil
215
+ def client_command_token
216
+ command_params.dig(:client_state, :command_token)
209
217
  end
210
218
 
211
- # TODO: revisit command token validation
212
- def client_token
213
- command_params[:token].to_s
219
+ def server_command_token
220
+ command_state[:command_token]
214
221
  end
215
222
 
216
- # TODO: revisit command token validation
217
- def valid_client_token?
218
- # return true unless Rails.configuration.turbo_boost_commands.validate_client_token
219
- # return false unless client_token.present?
220
- # return false unless message_verifier.valid_message?(client_token)
221
- # unmasked_client_token = message_verifier.verify(client_token)
222
- # unmasked_client_token == server_token
223
- true
223
+ def valid_command_token?
224
+ return true unless Rails.configuration.turbo_boost_commands.protect_from_forgery
225
+ return false unless client_command_token.present?
226
+ return false unless server_command_token.present?
227
+ server_command_token == message_verifier.verify(client_command_token, purpose: controller.request.session&.id)
228
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
229
+ false
224
230
  end
225
231
 
226
232
  def should_redirect?
227
- return false if controller.request.method.match?(/GET/i)
233
+ return false if controller.request.get?
228
234
  controller.request.accepts.include? Mime::Type.lookup_by_extension(:turbo_stream)
229
235
  end
230
236
 
@@ -235,12 +241,34 @@ class TurboBoost::Commands::Runner
235
241
 
236
242
  def response_type
237
243
  body = (controller.response_body.try(:join) || controller.response_body.to_s).strip
238
- return :body if body.match?(/<\/\s*body/i)
239
- return :frame if body.match?(/<\/\s*turbo-frame/i)
240
- return :stream if body.match?(/<\/\s*turbo-stream/i)
244
+ return :body if body.match?(/<\/\s*body/io)
245
+ return :frame if body.match?(/<\/\s*turbo-frame/io)
246
+ return :stream if body.match?(/<\/\s*turbo-stream/io)
241
247
  :unknown
242
248
  end
243
249
 
250
+ # Indicates if a TurboStream template exists for the current action.
251
+ # Any template with the format of :turbo_boost or :turbo_stream format is considered a match.
252
+ # @return [Boolean] true if a TurboStream template exists, false otherwise
253
+ def turbo_stream_template_exists?
254
+ controller.lookup_context.exists? controller.action_name, controller.lookup_context.prefixes, formats: [:turbo_boost, :turbo_stream]
255
+ end
256
+
257
+ def rendering_strategy
258
+ # Use the replace strategy if the follow things are true:
259
+ #
260
+ # 1. The command was triggered by the WINDOW driver
261
+ # 2. After the command finishes, normal Rails mechanics resume (i.e. prevent_controller_action was not called)
262
+ # 3. There is NO TurboStream template for the current action (i.e. example.turbo_boost.erb, example.turbo_frame.erb)
263
+ #
264
+ # TODO: Revisit the "Replace" strategy after morph ships with Turbo 8
265
+ if command_params[:driver] == "window" && controller_action_allowed?
266
+ return "Replace" unless turbo_stream_template_exists?
267
+ end
268
+
269
+ "Append"
270
+ end
271
+
244
272
  def append_success_to_response
245
273
  append_success_event_to_response_body
246
274
  append_streams_to_response_body
@@ -256,14 +284,16 @@ class TurboBoost::Commands::Runner
256
284
  command_instance.turbo_streams.each { |stream| append_to_response_body stream }
257
285
  end
258
286
 
259
- def append_command_token_to_response_body
260
- append_to_response_body turbo_stream.invoke("TurboBoost.Commands.token=", args: [new_token], camelize: false)
261
- rescue => error
262
- Rails.logger.error "TurboBoost::Commands::Runner failed to append the Command token to the response! #{error.message}"
263
- end
264
-
265
287
  def append_command_state_to_response_body
266
- append_to_response_body turbo_stream.invoke("TurboBoost.State.initialize", args: [state.to_json, state.to_sgid_param], camelize: false)
288
+ # use the masked token for the client state
289
+ command_state[:command_token] = message_verifier.generate(new_command_token, purpose: controller.request.session&.id)
290
+ client_state = command_state.to_json
291
+
292
+ # use the unmasked token for the signed (server) state
293
+ command_state[:command_token] = new_command_token
294
+ signed_state = command_state.to_sgid_param
295
+
296
+ append_to_response_body turbo_stream.invoke("TurboBoost.State.initialize", args: [client_state, signed_state], camelize: false)
267
297
  rescue => error
268
298
  Rails.logger.error "TurboBoost::Commands::Runner failed to append the Command state to the response! #{error.message}"
269
299
  end
@@ -316,10 +346,10 @@ class TurboBoost::Commands::Runner
316
346
 
317
347
  html = case response_type
318
348
  when :body
319
- match = controller.response.body.match(/<\/\s*body/i).to_s
349
+ match = controller.response.body.match(/<\/\s*body/io).to_s
320
350
  controller.response.body.sub match, [sanitized_content, match].join
321
351
  when :frame
322
- match = controller.response.body.match(/<\/\s*turbo-frame/i).to_s
352
+ match = controller.response.body.match(/<\/\s*turbo-frame/io).to_s
323
353
  controller.response.body.sub match, [sanitized_content, match].join
324
354
  else
325
355
  [controller.response.body, sanitized_content].join
@@ -336,10 +366,15 @@ class TurboBoost::Commands::Runner
336
366
  controller.response.set_header key.to_s, value.to_s
337
367
  end
338
368
 
339
- def append_to_response_headers(headers = {})
369
+ def append_to_response_headers(status = nil)
340
370
  return unless command_performed?
341
- headers.each { |key, val| append_response_header key, val }
342
- append_response_header "TurboBoost-Command", command_name
343
- append_response_header "TurboBoost-Command-Status", "HTTP #{controller.response.status} #{TurboBoost::Commands::HTTP_STATUS_CODES[controller.response.status]}"
371
+
372
+ values = [
373
+ status || "#{controller.response.status} #{TurboBoost::Commands::HTTP_STATUS_CODES[controller.response.status]}".delete(","),
374
+ rendering_strategy,
375
+ command_name
376
+ ]
377
+
378
+ append_response_header RESPONSE_HEADER, values.join(", ")
344
379
  end
345
380
  end