Skip to content

Commit

Permalink
Invoke commands via HTTP POST (#127)
Browse files Browse the repository at this point in the history
* Change commands to invoke via POST

* Moving toward simplified POST mechanics

* Some cleanup and doc comments

* Iterating toward full test suite

* Cleanup token handling and forgery protection

* Iterationg on some state experiments

* Some work on headers and state management

* Getting new test infrastructure in place

* Formatting in prep for next tests

* Get frame tests started

* Get frame tests in place and bring back data-src

* Working to get method and form drivers

* Get method driver working with allow/prevent controller action

* Get form driver working

* Addressing remaining items

* Iterating...

* Cleanup...

* Fighting flaky system tests

* Yaks

* More retries

* Minor javascript refactors

* Consistent key names

* Add some middleware safeguards
  • Loading branch information
hopsoft authored Feb 21, 2024
1 parent 9399429 commit 8a777dc
Show file tree
Hide file tree
Showing 66 changed files with 1,159 additions and 338 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.0', '3.1', '3.2', '3.3']
ruby-version: ['3.3']

steps:
- uses: actions/checkout@v3
Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/@turbo-boost/commands.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions app/assets/builds/@turbo-boost/commands.js.map

Large diffs are not rendered by default.

6 changes: 0 additions & 6 deletions app/controllers/concerns/turbo_boost/commands/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@
module TurboBoost::Commands::Controller
extend ActiveSupport::Concern

module ClassMethods
def turbo_boost_state(&block)
TurboBoost::Commands::State.assign_resolver(&block)
end
end

included do
before_action -> { turbo_boost.runner.run }
after_action -> { turbo_boost.runner.update_response }
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/drivers/form.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
function invokeCommand(form, payload = {}, event = {}) {
const invokeCommand = (form, payload = {}) => {
const input = form.querySelector('input[name="turbo_boost_command"]') || document.createElement('input')
input.type = 'hidden'
input.name = 'turbo_boost_command'
input.value = JSON.stringify(payload)
form.appendChild(input)
if (!form.contains(input)) form.appendChild(input)
}

export default { invokeCommand }
9 changes: 2 additions & 7 deletions app/javascript/drivers/frame.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import urls from '../urls'
import { invoke } from '../invoker'

function invokeCommand(frame, payload) {
const src = payload.src
payload = { ...payload }
delete payload.src
frame.src = urls.build(src, payload)
}
const invokeCommand = (_, payload) => invoke(payload)

export default { invokeCommand }
2 changes: 1 addition & 1 deletion app/javascript/drivers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import windowDriver from './window'

function src(element, frame) {
frame = frame || { dataset: {} }
return element.href || frame.src || frame.dataset.turboBoostSrc || location.href
return element.href || frame.src || frame.dataset.src || location.href
}

function find(element) {
Expand Down
37 changes: 29 additions & 8 deletions app/javascript/drivers/method.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import urls from '../urls'

function invokeCommand(element, payload = {}) {
const src = payload.src
payload = { ...payload }
delete payload.src
delete payload.href
element.setAttribute('href', urls.build(src, payload))
let activeElement
let activePayload

const reset = () => {
activeElement = null
activePayload = null
}

const invokeCommand = (element, payload = {}) => {
activeElement = element
activePayload = payload
}

const amendForm = form => {
try {
if (!activeElement) return
if (form.getAttribute('method') !== activeElement.dataset.turboMethod) return
if (form.getAttribute('action') !== activeElement.href) return

const input = form.querySelector('input[name="turbo_boost_command"]') || document.createElement('input')
input.type = 'hidden'
input.name = 'turbo_boost_command'
input.value = JSON.stringify(activePayload)
if (!form.contains(input)) form.appendChild(input)
} finally {
reset() // ensure reset
}
}

document.addEventListener('submit', event => amendForm(event.target), true)

export default { invokeCommand }
56 changes: 2 additions & 54 deletions app/javascript/drivers/window.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,5 @@
import state from '../state'
import { dispatch } from '../events'
import lifecycle from '../lifecycle'
import urls from '../urls'
import renderer from '../renderer'
import { invoke } from '../invoker'

function aborted(event) {
const xhr = event.target
dispatch(lifecycle.events.abort, document, {
detail: { ...event.detail, xhr }
})
}

function errored(event) {
const xhr = event.target

const append =
xhr.getResponseHeader('TurboBoost') === 'Append' ||
xhr.getResponseHeader('Content-Type').startsWith('text/vnd.turbo-boost.html')

if (append) renderer.append(xhr.responseText)

const error = `Server returned a ${xhr.status} status code! TurboBoost Commands require 2XX-3XX status codes.`

dispatch(lifecycle.events.clientError, document, { detail: { ...event.detail, error, xhr } }, true)
}

function loaded(event) {
const xhr = event.target
if (xhr.status < 200 || xhr.status > 399) return errored(event)
const content = xhr.responseText
const append =
xhr.getResponseHeader('TurboBoost') === 'Append' ||
xhr.getResponseHeader('Content-Type').startsWith('text/vnd.turbo-boost.html')
append ? renderer.append(xhr.responseText) : renderer.replaceDocument(xhr.responseText)
}

function invokeCommand(payload) {
const src = payload.src
payload = { ...payload }
delete payload.src

try {
const xhr = new XMLHttpRequest()
xhr.open('GET', urls.build(src, payload), true)
xhr.setRequestHeader('Accept', 'text/vnd.turbo-boost.html, text/html, application/xhtml+xml')
xhr.addEventListener('abort', aborted)
xhr.addEventListener('error', errored)
xhr.addEventListener('load', loaded)
xhr.send()
} catch (ex) {
const message = `Unexpected error sending HTTP request! ${ex.message}`
errored(ex, { detail: { message } })
}
}
const invokeCommand = (_, payload = {}) => invoke(payload)

export default { invokeCommand }
43 changes: 43 additions & 0 deletions app/javascript/headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const RESPONSE_HEADER = 'TurboBoost-Command'

const types = {
boost: 'text/vnd.turbo-boost.html',
stream: 'text/vnd.turbo-stream.html',
html: 'text/html',
xhtml: 'application/xhtml+xml',
json: 'application/json'
}

// Prepares request headers for TurboBoost Command invocations
const prepare = (headers = {}) => {
headers = { ...headers }

// Assign Accept values
const accepts = (headers['Accept'] || '')
.split(',')
.map(val => val.trim())
.filter(val => val.length)

accepts.unshift(types.boost, types.stream, types.html, types.xhtml)
headers['Accept'] = [...new Set(accepts)].join(', ')

// Assign Content-Type (Commands POST JSON via fetch/XHR)
headers['Content-Type'] = types.json

// Assign X-Requested-With for XHR detection
headers['X-Requested-With'] = 'XMLHttpRequest'

return headers
}

// Tokenizes the 'TurboBoost-Command' HTTP response header value
const tokenize = value => {
if (value) {
const [status, strategy, name] = value.split(', ')
return { status, strategy, name }
}

return {}
}

export default { prepare, tokenize, RESPONSE_HEADER }
14 changes: 5 additions & 9 deletions app/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ import elements from './elements'
import lifecycle from './lifecycle'
import logger from './logger'
import state from './state'
import urls from './urls'
import uuids from './uuids'
import VERSION from './version'

const TurboBoost = self.TurboBoost || {}

const Commands = {
VERSION,
busy: false,
active: false,
confirmation,
logger,
schema,
Expand All @@ -35,9 +34,9 @@ function buildCommandPayload(id, element) {
elementId: element.id.length > 0 ? element.id : null,
elementAttributes: elements.buildAttributePayload(element),
startedAt: Date.now(),
token: Commands.token, // command token (used for CSRF protection)
signedState: state.signed, // server side state
clientState: state.changed // client side state (delta of optimistic updates)
changedState: state.changed, // changed-state (delta of optimistic updates)
clientState: state.current, // client-side state
signedState: state.signed // server-side state
}
}

Expand Down Expand Up @@ -85,9 +84,6 @@ async function invokeCommand(event) {

if (['frame', 'window'].includes(driver.name)) event.preventDefault()

Commands.busy = true
setTimeout(() => (Commands.busy = false), 10)

switch (driver.name) {
case 'method':
return driver.invokeCommand(element, payload)
Expand All @@ -96,7 +92,7 @@ async function invokeCommand(event) {
case 'frame':
return driver.invokeCommand(driver.frame, payload)
case 'window':
return driver.invokeCommand(payload)
return driver.invokeCommand(self, payload)
}
} catch (error) {
dispatch(commandEvents.clientError, element, {
Expand Down
39 changes: 39 additions & 0 deletions app/javascript/invoker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import headers from './headers'
import lifecycle from './lifecycle'
import state from './state'
import urls from './urls'
import { dispatch } from './events'
import { render } from './renderer'

const parseError = error => {
const errorMessage = `Unexpected error performing a TurboBoost Command! ${error.message}`
dispatch(lifecycle.events.clientError, document, { detail: { error: errorMessage } }, true)
}

const parseAndRenderResponse = response => {
const { strategy } = headers.tokenize(response.headers.get(headers.RESPONSE_HEADER))

// FAIL: Status outside the range of 200-399
if (response.status < 200 || response.status > 399) {
const error = `Server returned a ${response.status} status code! TurboBoost Commands require 2XX-3XX status codes.`
dispatch(lifecycle.events.serverError, document, { detail: { error, response } }, true)
}

response.text().then(content => render(strategy, content))
}

const invoke = (payload = {}) => {
try {
fetch(urls.commandInvocationURL.href, {
method: 'POST',
headers: headers.prepare({}),
body: JSON.stringify(payload)
})
.then(parseAndRenderResponse)
.catch(parseError)
} catch (error) {
parseError(error)
}
}

export { invoke }
26 changes: 14 additions & 12 deletions app/javascript/renderer.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
function replaceDocument(content) {
const head = '<html'
const tail = '</html'
const headIndex = content.indexOf(head)
const tailIndex = content.lastIndexOf(tail)
if (headIndex >= 0 && tailIndex >= 0) {
const html = content.slice(content.indexOf('>', headIndex) + 1, tailIndex)
document.documentElement.innerHTML = html
}
const append = content => {
document.body.insertAdjacentHTML('beforeend', content)
}

function append(content) {
document.body.insertAdjacentHTML('beforeend', content)
// TODO: Revisit the "Replace" strategy after morph ships with Turbo 8
const replace = content => {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
document.head.innerHTML = doc.head.innerHTML
document.body.innerHTML = doc.body.innerHTML
}

export const render = (strategy, content) => {
if (strategy.match(/^Append$/i)) return append(content)
if (strategy.match(/^Replace$/i)) return replace(content)
}

export default { append, replaceDocument }
export default { render }
45 changes: 18 additions & 27 deletions app/javascript/turbo.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,37 @@
import state from './state'
import renderer from './renderer'
import { dispatch } from './events'
import headers from './headers'
import lifecycle from './lifecycle'
import { dispatch } from './events'
import { render } from './renderer'

const frameSources = {}

// fires before making a turbo HTTP request
addEventListener('turbo:before-fetch-request', event => {
const frame = event.target.closest('turbo-frame')
const { fetchOptions } = event.detail

// command invoked and busy
if (self.TurboBoost?.Commands?.busy) {
let acceptHeaders = ['text/vnd.turbo-boost.html', fetchOptions.headers['Accept']]
acceptHeaders = acceptHeaders.filter(entry => entry && entry.trim().length > 0).join(', ')
fetchOptions.headers['Accept'] = acceptHeaders
}
})

// fires after receiving a turbo HTTP response
addEventListener('turbo:before-fetch-response', event => {
const frame = event.target.closest('turbo-frame')
if (frame?.id && frame?.src) frameSources[frame.id] = frame.src

const { fetchResponse: response } = event.detail
const header = response.header(headers.RESPONSE_HEADER)

if (frame) frameSources[frame.id] = frame.src
if (!header) return

if (response.header('TurboBoost')) {
if (response.statusCode < 200 || response.statusCode > 399) {
const error = `Server returned a ${response.statusCode} status code! TurboBoost Commands require 2XX-3XX status codes.`
dispatch(lifecycle.events.clientError, document, { detail: { ...event.detail, error } }, true)
}
// We'll take it from here Hotwire...
event.preventDefault()
const { statusCode } = response
const { strategy } = headers.tokenize(header)

if (response.header('TurboBoost') === 'Append') {
event.preventDefault()
response.responseText.then(content => renderer.append(content))
}
// FAIL: Status outside the range of 200-399
if (statusCode < 200 || statusCode > 399) {
const error = `Server returned a ${status} status code! TurboBoost Commands require 2XX-3XX status codes.`
dispatch(lifecycle.events.clientError, document, { detail: { error, response } }, true)
}

response.responseHTML.then(content => render(strategy, content))
})

// fires when a frame element is navigated and finishes loading
addEventListener('turbo:frame-load', event => {
const frame = event.target.closest('turbo-frame')
frame.dataset.turboBoostSrc = frameSources[frame.id] || frame.src || frame.dataset.turboBoostSrc
frame.dataset.src = frameSources[frame.id] || frame.src || frame.dataset.src
delete frameSources[frame.id]
})
Loading

0 comments on commit 8a777dc

Please sign in to comment.