-
-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Invoke commands via HTTP POST (#127)
* 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
Showing
66 changed files
with
1,159 additions
and
338 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
}) |
Oops, something went wrong.