@hytts/hytts
Advanced tools
Comparing version 0.1.0 to 0.2.3
@@ -0,96 +1,124 @@ | ||
declare const eventRemovalSymbol: unique symbol; | ||
declare global { | ||
interface Element { | ||
[eventRemovalSymbol]?: AbortController[]; | ||
} | ||
} | ||
declare function addEventListener( | ||
elementId: string, | ||
eventName: string, | ||
eventListener: EventListenerOrEventListenerObject, | ||
options?: boolean | AddEventListenerOptions, | ||
): void; | ||
/** | ||
* Used to select a frame within the document, which is either the document's body element | ||
* or the id of a `hy-frame` element, in which case the frame id is expected to be unique. | ||
* Used to select a `hy-frame` element within the document. It is assumed that there always is a | ||
* root frame directly below the document's body. | ||
*/ | ||
type FrameSelector = string; | ||
type FrameId = string; | ||
/** | ||
* Simulates a full page navigation to a route by first pushing a new entry onto the browser's | ||
* history stack with the route's URL and then loads the route to update the selected frame. | ||
* Updates the contents of the frame with the element returned by the given callback. Ensures that | ||
* only one update can be in-flight concurrently for the frame. If a new update is issued before a | ||
* previous one finished, the previous one is immediately aborted so that it can't have any effect | ||
* on the frame anymore and the new update is started immediately. After a successful update, all | ||
* pending updates for any of the frame's transitive child frames are aborted. | ||
* | ||
* Note that we make the history change first to simulate the native browser behavior. In case of an | ||
* error or a long loading time, the URL is already updated, so a browser reload will then reload the | ||
* new URL instead of the old one. | ||
* | ||
* If the server's response to the route is a redirect, the redirect is followed automatically. In | ||
* that case, the previously pushed history entry is replaced with the new URL after the redirected | ||
* response is fetched. This is different from the normal browser behavior, where the history stack | ||
* is updated before requested the redirected URL. Due to security considerations, this behavior | ||
* cannot be reimplemented with JavaScript. | ||
* | ||
* @param frameSelector Selects the frame that should be updated. | ||
* @param routeUrl The URL of the route that should be loaded and navigated to. | ||
* @param frameId The id of the frame that should be updated. | ||
* @param getFrameElement A callback that returns the HTML element the frame should be updated with. | ||
* If `undefined` is returned, the frame's content remains unmodified. The callback should use the | ||
* given `AbortSignal` to abort the update as soon as possible once a newer update is started. | ||
*/ | ||
declare function navigateToRoute(frameSelector: FrameSelector, routeUrl: string): Promise<void>; | ||
declare function updateFrame( | ||
frameId: FrameId, | ||
getFrameElement: (frame: Element, abortSignal: AbortSignal) => Promise<Element | undefined>, | ||
): Promise<void>; | ||
type SubmissionOptions = { | ||
/** The URL that should be submitted to. */ | ||
readonly href: string; | ||
/** The form element that should be submitted. */ | ||
readonly form: HTMLFormElement; | ||
/** URL-encoded, optional data that is sent in addition to the form data. */ | ||
readonly additionalData?: string; | ||
/** | ||
* Selects the frame that should be updated with the request's response. If none is given, | ||
* updates the form's nearest ancestor frame. | ||
*/ | ||
readonly frameId?: FrameId; | ||
/** | ||
* If `true`, updates the browser's history stack after successful form submission with either | ||
* `href` or, if the server redirects, the redirect URL. Defaults to `true` for form submissions | ||
* targeting the root frame. | ||
*/ | ||
readonly updateHistory?: boolean; | ||
}; | ||
declare function submitForm({ | ||
href, | ||
form, | ||
additionalData, | ||
frameId, | ||
updateHistory, | ||
}: SubmissionOptions): Promise<void>; | ||
/** The list of HTTP verbs supported by HyTTS. */ | ||
declare const httpMethods: readonly ["GET", "POST"]; | ||
/** The list of HTTP verbs supported by HyTTS. */ | ||
type HttpMethod = (typeof httpMethods)[number]; | ||
type NavigationOptions = { | ||
/** The URL that should be navigated to. */ | ||
readonly href: string; | ||
/** The HTTP method that should be used for the request to the server. */ | ||
readonly httpMethod: HttpMethod; | ||
/** The URL-encoded body parameters that should be sent with the (non-`GET`) request. */ | ||
readonly bodyParams?: string; | ||
/** | ||
* The URL that should be pushed onto the history stack if `updateHistory` is `true`. Defaults | ||
* to the `href` prop if `historyHref` is `undefined`. | ||
*/ | ||
readonly historyHref?: string; | ||
/** | ||
* If `true`, updates the browser's history stack. Defaults to `true` for requests targeting the | ||
* root frame. | ||
*/ | ||
readonly updateHistory?: boolean; | ||
/** Selects the frame that should be updated. */ | ||
readonly frameId: FrameId; | ||
}; | ||
/** | ||
* Simulates a full page navigation while also executing the given action. The selected frame is | ||
* updated with the action's response HTML. Before the action is invoked, the given route URL is | ||
* pushed onto the browser's history stack. The server must serve a route at this URL, otherwise | ||
* the browser's reload behavior is broken because such a reload always issues a GET request for a | ||
* route instead of a POST request to an action. | ||
* Simulates a full page navigation to a URL by fetching he server-rendered HTML, updating the | ||
* target frame and afterwards, optionally, pushing a new entry onto the browser's history stack. | ||
* | ||
* Note that we make the history change first to simulate the browser behavior. In case of an error | ||
* or a long loading time, the URL is already updated, so a browser reload will then reload the new | ||
* URL instead of the old one. In that case, however, it is unclear whether the action has already | ||
* been carried out, will be carried out eventually, or whether it never reached the server in the | ||
* first place. This behavior is somewhat different from the native browser behavior, which would | ||
* re-issue the POST request again in such a situation, typically after warning the user that she | ||
* is about to send the data again. So also in that case, the user does not know whether the action | ||
* is carried out twice. | ||
* When navigating to a non-`GET` route and a history entry is pushed onto the stack, the server | ||
* must serve a `GET` route at this URL, otherwise the browser's reload behavior is broken because | ||
* such a reload always issues a GET request as opposed to using the original HTTP method. | ||
* | ||
* If the server's response to the route is a redirect, the redirect is followed automatically. In | ||
* that case, the previously pushed history entry is replaced with the new URL after the redirected | ||
* response is fetched. This is different from the normal browser behavior, where the history stack | ||
* is updated before requested the redirected URL. Due to security considerations, this behavior | ||
* cannot be reimplemented with JavaScript. | ||
* When a navigation is aborted, e.g., due to a browser reload or another frame update, for which | ||
* the server potentially carries out side effects (which typically happens for POST requests), it | ||
* is unclear whether the side effects have already been carried out, will be carried out | ||
* eventually, or whether the request never reached the server in the first place. In this case, the | ||
* HyTTS behavior for browser reloads is somewhat different from the native browser behavior, which | ||
* would re-issue the POST request again, typically after warning the user that she is about to send | ||
* the data again. With HyTTS, by contrast, did not update the browser history before the request | ||
* came through, so a GET request to the URL before the navigation is issues on a browser fresh. | ||
* Neither the browser's behavior nor HyTTS's behavior is ideal in that case, as the user has no | ||
* idea whether or not her "aborted" actions have already been carried out. | ||
* | ||
* @param frameSelector Selects the frame that should be updated. | ||
* @param actionUrl The URL of the action that should be executed. | ||
* @param actionParams The action's URL encoded params. | ||
* @param routeUrlForHistory The route URL that should be pushed onto the browser's history stack. | ||
* If the server's response is a redirect, the redirect is followed automatically, and, optionally, | ||
* the browser's history stack is updated accordingly. | ||
*/ | ||
declare function navigateToAction( | ||
frameSelector: FrameSelector, | ||
actionUrl: string, | ||
actionParams: string, | ||
routeUrlForHistory: string | ||
): Promise<void>; | ||
/** | ||
* Loads the HTML for the given `routeUrl` and updates the selected frame accordingly. | ||
* @param frameSelector Selects the frame that should be updated. | ||
* @param routeUrl The URL of the route that should be loaded. | ||
*/ | ||
declare function loadRoute(frameSelector: FrameSelector, routeUrl: string): Promise<void>; | ||
/** | ||
* Executes the given action and updates the selected frame based on the returned HTML. | ||
* @param frameSelector Selects the frame that should be updated. | ||
* @param actionUrl The URL of the action that should be executed. | ||
* @param actionParams The action's URL encoded params. | ||
*/ | ||
declare function executeAction( | ||
frameSelector: FrameSelector, | ||
actionUrl: string, | ||
actionParams: string | ||
): Promise<void>; | ||
/** | ||
* Updates the contents of the selected frame with the element returned by the given callback. | ||
* Ensures that only one update can be in-flight concurrently for the frame. If a new update is issued | ||
* before a previous one finished, the previous one is immediately aborted so that it can't have any | ||
* effect on the frame anymore and the new update is started immediately. After a successful update, | ||
* all pending updates for any of the frame's transitive child frames are aborted. | ||
* @param frameSelector Selects the frame that should be updated. | ||
* @param getFrameElement A callback that returns the HTML element the frame should be updated with. | ||
* The callback should use the given `AbortSignal` to abort the update as soon | ||
* as possible once a newer update is started. | ||
*/ | ||
declare function updateFrame( | ||
frameSelector: FrameSelector, | ||
getFrameElement: (frame: Element, abortSignal: AbortSignal) => Promise<Element> | ||
): Promise<void>; | ||
declare function navigateTo({ | ||
href, | ||
httpMethod, | ||
bodyParams, | ||
updateHistory, | ||
historyHref, | ||
frameId, | ||
}: NavigationOptions): Promise<void>; | ||
declare const externalApi: { | ||
readonly executeAction: typeof executeAction; | ||
readonly loadRoute: typeof loadRoute; | ||
readonly navigateToAction: typeof navigateToAction; | ||
readonly navigateToRoute: typeof navigateToRoute; | ||
readonly navigateTo: typeof navigateTo; | ||
readonly updateFrame: typeof updateFrame; | ||
readonly addEventListener: typeof addEventListener; | ||
readonly submitForm: typeof submitForm; | ||
}; | ||
@@ -97,0 +125,0 @@ declare global { |
"use strict"; | ||
// source/browser/events.browser.ts | ||
var eventRemovalSymbol = Symbol(); | ||
function addEventListener(elementId, eventName, eventListener, options) { | ||
const element = document.getElementById(elementId); | ||
if (!element) { | ||
throw new Error(`Unable to find element with id '${elementId}'.`); | ||
} | ||
const abortController = new AbortController(); | ||
const eventOptions = | ||
typeof options === "boolean" | ||
? { capture: options, signal: abortController.signal } | ||
: { ...options, signal: abortController.signal }; | ||
if (typeof options === "object" && options.signal) { | ||
options.signal.addEventListener("abort", () => abortController.abort()); | ||
} | ||
element.addEventListener(eventName, eventListener, eventOptions); | ||
element[eventRemovalSymbol] ?? (element[eventRemovalSymbol] = []); | ||
element[eventRemovalSymbol].push(abortController); | ||
} | ||
function removeEventListeners(element) { | ||
element[eventRemovalSymbol]?.forEach((abortController) => abortController.abort()); | ||
} | ||
// source/browser/log.browser.ts | ||
var log = { | ||
error: (error) => console.error(error), | ||
warn: (warning) => console.warn(warning), | ||
info: (message) => console.log(message) | ||
error: (error) => console.error(error), | ||
warn: (warning) => console.warn(warning), | ||
info: (message) => console.log(message), | ||
}; | ||
@@ -12,309 +35,413 @@ | ||
function reconcile(currentElement, newElement) { | ||
prepareScriptElements(newElement); | ||
return reconcileNode(currentElement, newElement); | ||
prepareScriptElements(newElement); | ||
return reconcileNode(currentElement, newElement); | ||
} | ||
function reconcileNode(currentNode, newNode) { | ||
if (currentNode.nodeType !== newNode.nodeType || currentNode.nodeName !== newNode.nodeName) { | ||
return newNode; | ||
} | ||
if (currentNode.nodeType === Node.TEXT_NODE) { | ||
if (currentNode.nodeValue !== newNode.nodeValue) { | ||
currentNode.nodeValue = newNode.nodeValue; | ||
if (currentNode.nodeType !== newNode.nodeType || currentNode.nodeName !== newNode.nodeName) { | ||
return newNode; | ||
} | ||
} else if (currentNode.nodeType === Node.ELEMENT_NODE) { | ||
if (newNode.nodeName === "SCRIPT") { | ||
return newNode; | ||
if (currentNode.nodeType === Node.TEXT_NODE) { | ||
if (currentNode.nodeValue !== newNode.nodeValue) { | ||
currentNode.nodeValue = newNode.nodeValue; | ||
} | ||
} else if (currentNode.nodeType === Node.ELEMENT_NODE) { | ||
if (newNode.nodeName === "SCRIPT") { | ||
return newNode; | ||
} | ||
reconcileElement(currentNode, newNode); | ||
} | ||
reconcileElement(currentNode, newNode); | ||
} | ||
return currentNode; | ||
return currentNode; | ||
} | ||
function reconcileElement(currentElement, newElement) { | ||
reconcileAttributes(currentElement, newElement); | ||
const hasChildren = currentElement.firstChild || newElement.firstChild; | ||
if (hasChildren) { | ||
reconcileChildren(currentElement, newElement); | ||
} | ||
removeEventListeners(currentElement); | ||
reconcileAttributes(currentElement, newElement); | ||
const hasChildren = currentElement.firstChild ?? newElement.firstChild; | ||
if (hasChildren) { | ||
reconcileChildren(currentElement, newElement); | ||
} | ||
} | ||
function reconcileAttributes(currentElement, newElement) { | ||
for (const newAttribute of newElement.getAttributeNames()) { | ||
if (newAttribute.startsWith("data-hy-view-") || newAttribute === "style") { | ||
continue; | ||
for (const newAttribute of newElement.getAttributeNames()) { | ||
if (newAttribute.startsWith("data-hy-view-") || newAttribute === "style") { | ||
continue; | ||
} | ||
const value = newElement.getAttribute(newAttribute); | ||
if (value && currentElement.getAttribute(newAttribute) !== value) { | ||
currentElement.setAttribute(newAttribute, value); | ||
} | ||
} | ||
const value = newElement.getAttribute(newAttribute); | ||
if (value && currentElement.getAttribute(newAttribute) !== value) { | ||
currentElement.setAttribute(newAttribute, value); | ||
for (const currentAttribute of currentElement.getAttributeNames()) { | ||
if (currentAttribute.startsWith("data-hy-view-") || currentAttribute === "style") { | ||
continue; | ||
} | ||
if (!newElement.hasAttribute(currentAttribute)) { | ||
currentElement.removeAttribute(currentAttribute); | ||
} | ||
} | ||
} | ||
for (const currentAttribute of currentElement.getAttributeNames()) { | ||
if (currentAttribute.startsWith("data-hy-view-") || currentAttribute === "style") { | ||
continue; | ||
} | ||
if (!newElement.hasAttribute(currentAttribute)) { | ||
currentElement.removeAttribute(currentAttribute); | ||
} | ||
} | ||
} | ||
function reconcileChildren(currentElement, newElement) { | ||
const currentKeyMap = getKeyMap(currentElement); | ||
const newKeyMap = getKeyMap(newElement); | ||
let currentChild = currentElement.firstChild; | ||
let newChild = newElement.firstChild; | ||
while (currentChild || newChild) { | ||
if (currentChild && !newChild) { | ||
const oldCurrent = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
currentElement.removeChild(oldCurrent); | ||
} else if (!currentChild && newChild) { | ||
const addChild = newChild; | ||
newChild = newChild.nextSibling; | ||
currentElement.appendChild(addChild); | ||
} else if (currentChild && newChild) { | ||
const currentKey = currentChild.nodeType === Node.ELEMENT_NODE ? getKey(currentChild) : void 0; | ||
const newKey = newChild.nodeType === Node.ELEMENT_NODE ? getKey(newChild) : void 0; | ||
if (newKey === currentKey) { | ||
const element = reconcileNode(currentChild, newChild); | ||
const oldCurrent = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
newChild = newChild.nextSibling; | ||
if (oldCurrent !== element) { | ||
currentElement.replaceChild(element, oldCurrent); | ||
} | ||
} else { | ||
if (!newKey) { | ||
const nextNewChild = newChild.nextSibling; | ||
currentElement.insertBefore(newChild, currentChild); | ||
newChild = nextNewChild; | ||
} else { | ||
const existingChild = currentKeyMap.get(newKey); | ||
const nextNewChild = newChild.nextSibling; | ||
if (existingChild) { | ||
const element = reconcileNode(existingChild, newChild); | ||
currentElement.insertBefore(element, currentChild); | ||
if (element !== existingChild) { | ||
existingChild.remove(); | ||
const currentKeyMap = getKeyMap(currentElement); | ||
const newKeyMap = getKeyMap(newElement); | ||
let currentChild = currentElement.firstChild; | ||
let newChild = newElement.firstChild; | ||
while (currentChild ?? newChild) { | ||
if (currentChild && !newChild) { | ||
const oldCurrent = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
currentElement.removeChild(oldCurrent); | ||
} else if (!currentChild && newChild) { | ||
const addChild = newChild; | ||
newChild = newChild.nextSibling; | ||
currentElement.appendChild(addChild); | ||
} else if (currentChild && newChild) { | ||
const currentKey = | ||
currentChild.nodeType === Node.ELEMENT_NODE ? getKey(currentChild) : void 0; | ||
const newKey = newChild.nodeType === Node.ELEMENT_NODE ? getKey(newChild) : void 0; | ||
if (newKey === currentKey) { | ||
const element = reconcileNode(currentChild, newChild); | ||
const oldCurrent = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
newChild = newChild.nextSibling; | ||
if (oldCurrent !== element) { | ||
currentElement.replaceChild(element, oldCurrent); | ||
} | ||
} else { | ||
if (!newKey) { | ||
const nextNewChild = newChild.nextSibling; | ||
currentElement.insertBefore(newChild, currentChild); | ||
newChild = nextNewChild; | ||
} else { | ||
const existingChild = currentKeyMap.get(newKey); | ||
const nextNewChild = newChild.nextSibling; | ||
if (existingChild) { | ||
const element = reconcileNode(existingChild, newChild); | ||
currentElement.insertBefore(element, currentChild); | ||
if (element !== existingChild) { | ||
existingChild.remove(); | ||
} | ||
} else { | ||
currentElement.insertBefore(newChild, currentChild); | ||
} | ||
if (currentKey && !newKeyMap.get(currentKey)) { | ||
const childToRemove = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
childToRemove.remove(); | ||
} | ||
newChild = nextNewChild; | ||
} | ||
} | ||
} else { | ||
currentElement.insertBefore(newChild, currentChild); | ||
} | ||
if (currentKey && !newKeyMap.get(currentKey)) { | ||
const childToRemove = currentChild; | ||
currentChild = currentChild.nextSibling; | ||
childToRemove.remove(); | ||
} | ||
newChild = nextNewChild; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
function getKey(element) { | ||
return element.getAttribute("data-hy-key"); | ||
return element.getAttribute("data-hy-key"); | ||
} | ||
function getKeyMap(element) { | ||
const keyMap = /* @__PURE__ */ new Map(); | ||
for (let child = element.firstChild; child; child = child.nextSibling) { | ||
if (child.nodeType === Node.ELEMENT_NODE) { | ||
const elementChild = child; | ||
const key = getKey(elementChild); | ||
if (key) { | ||
if (keyMap.has(key)) { | ||
console.warn(`Key '${key}' is used more than once.`); | ||
const keyMap = /* @__PURE__ */ new Map(); | ||
for (let child = element.firstChild; child; child = child.nextSibling) { | ||
if (child.nodeType === Node.ELEMENT_NODE) { | ||
const elementChild = child; | ||
const key = getKey(elementChild); | ||
if (key) { | ||
if (keyMap.has(key)) { | ||
log.warn(`Key '${key}' is used more than once.`); | ||
} | ||
keyMap.set(key, elementChild); | ||
} | ||
} | ||
keyMap.set(key, elementChild); | ||
} | ||
} | ||
} | ||
return keyMap; | ||
return keyMap; | ||
} | ||
function prepareScriptElements(newElement) { | ||
const cspNonce = document.querySelector('meta[name="hy-csp-nonce"]')?.content; | ||
const fragment = document.createDocumentFragment(); | ||
fragment.appendChild(newElement); | ||
fragment.querySelectorAll("script").forEach((currentScript) => { | ||
const newScript = document.createElement("script"); | ||
for (const attribute of currentScript.getAttributeNames()) { | ||
const value = newScript.getAttribute(attribute); | ||
if (value) { | ||
newScript.setAttribute(attribute, value); | ||
} | ||
} | ||
newScript.textContent = currentScript.textContent; | ||
newScript.async = false; | ||
newScript.nonce = cspNonce; | ||
currentScript.parentNode?.replaceChild(newScript, currentScript); | ||
}); | ||
const cspNonce = document.querySelector('meta[name="hy-csp-nonce"]')?.content; | ||
const fragment = document.createDocumentFragment(); | ||
fragment.appendChild(newElement); | ||
fragment.querySelectorAll("script").forEach((currentScript) => { | ||
const newScript = document.createElement("script"); | ||
for (const attribute of currentScript.getAttributeNames()) { | ||
const value = newScript.getAttribute(attribute); | ||
if (value) { | ||
newScript.setAttribute(attribute, value); | ||
} | ||
} | ||
newScript.textContent = currentScript.textContent; | ||
newScript.async = false; | ||
newScript.nonce = cspNonce; | ||
currentScript.parentNode.replaceChild(newScript, currentScript); | ||
}); | ||
} | ||
// source/browser/frame.browser.ts | ||
async function navigateToRoute(frameSelector, routeUrl) { | ||
return withHistoryUpdate( | ||
frameSelector, | ||
routeUrl, | ||
(frame, signal) => fetchRoute(frame, routeUrl, signal) | ||
); | ||
var rootFrameId = "root"; | ||
function updateFrame(frameId, getFrameElement) { | ||
const frame = document.getElementById(frameId); | ||
if (!frame || frame.tagName.toLowerCase() !== "hy-frame") { | ||
throw new Error(`Frame '${frameId}' not found.`); | ||
} | ||
const abortController = new AbortController(); | ||
const updateFramePromise = (async () => { | ||
await abortPreviousUpdate(frame); | ||
const childFrames = [...document.querySelectorAll("hy-frame")].filter( | ||
(childFrame) => childFrame !== frame && frame.contains(childFrame), | ||
); | ||
const newFrame = await getFrameElement(frame, abortController.signal); | ||
if (newFrame) { | ||
abortController.signal.throwIfAborted(); | ||
reconcile(frame, newFrame); | ||
await Promise.all(childFrames.map(abortPreviousUpdate)); | ||
} | ||
})(); | ||
pendingUpdates.set(frame, [abortController, updateFramePromise]); | ||
return updateFramePromise; | ||
} | ||
function navigateToAction(frameSelector, actionUrl, actionParams, routeUrlForHistory) { | ||
return withHistoryUpdate( | ||
frameSelector, | ||
routeUrlForHistory, | ||
(frame, signal) => fetchAction(frame, actionUrl, actionParams, signal) | ||
); | ||
var pendingUpdates = /* @__PURE__ */ new WeakMap(); | ||
async function abortPreviousUpdate(frame) { | ||
const [previousSignal, previousPromise] = pendingUpdates.get(frame) ?? []; | ||
previousSignal?.abort(); | ||
try { | ||
await previousPromise; | ||
} catch (e) { | ||
if (e instanceof DOMException && e.name === "AbortError") { | ||
} else { | ||
log.warn(`An error occurred in an aborted frame update: ${e}`); | ||
} | ||
} | ||
} | ||
async function withHistoryUpdate(frameSelector, historyUrl, fetch2) { | ||
const thisNavigationId = ++navigationId; | ||
history.pushState({ [navigationIdKey]: thisNavigationId }, "", historyUrl); | ||
let response = void 0; | ||
await updateFrame(frameSelector, async (frame, signal) => { | ||
response = await fetch2(frame, signal); | ||
return extractFrameFromResponse(frame, response); | ||
}); | ||
if (response.redirected && history.state[navigationIdKey] === thisNavigationId) { | ||
history.replaceState(null, "", response.url); | ||
} | ||
async function fetchFrame(frame, url, fetchOptions) { | ||
try { | ||
return await fetch(url, { | ||
...fetchOptions, | ||
headers: { | ||
...fetchOptions.headers, | ||
// see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers | ||
"x-hy": "true", | ||
"x-hy-frame-id": frame.getAttribute("id") ?? "error: unknown frame id", | ||
...(!fetchOptions.method || fetchOptions.method === "GET" | ||
? {} | ||
: { "content-type": "application/x-www-form-urlencoded" }), | ||
}, | ||
}); | ||
} catch (e) { | ||
if (isNetworkError(e)) { | ||
frame.dispatchEvent(new CustomEvent("hy:offline", { bubbles: true, cancelable: true })); | ||
} | ||
throw e; | ||
} | ||
} | ||
function loadRoute(frameSelector, routeUrl) { | ||
return updateFrame( | ||
frameSelector, | ||
async (frame, signal) => extractFrameFromResponse(frame, await fetchRoute(frame, routeUrl, signal)) | ||
); | ||
function isNetworkError(e) { | ||
return e instanceof TypeError; | ||
} | ||
async function executeAction(frameSelector, actionUrl, actionParams) { | ||
return updateFrame( | ||
frameSelector, | ||
async (frame, signal) => extractFrameFromResponse(frame, await fetchAction(frame, actionUrl, actionParams, signal)) | ||
); | ||
async function extractFrameFromResponse(frame, response, signal) { | ||
const document2 = await parseHTML(response); | ||
signal?.throwIfAborted(); | ||
const frameId = frame.getAttribute("id") ?? ""; | ||
const newFrameElement = document2.getElementById(frameId); | ||
if (!newFrameElement || newFrameElement.tagName.toLowerCase() !== "hy-frame") { | ||
const updateFrameForErrorResponses = frame.dispatchEvent( | ||
new CustomEvent("hy:frame-missing", { | ||
bubbles: true, | ||
cancelable: true, | ||
detail: { response, document: document2 }, | ||
}), | ||
); | ||
const message = `Frame '${frameId}' not found in the server's response.`; | ||
if (updateFrameForErrorResponses && response.status >= 300) { | ||
log.warn(message); | ||
const newFrame = frame.cloneNode(); | ||
newFrame.replaceChildren(...document2.body.children); | ||
return newFrame; | ||
} else { | ||
throw new Error(message); | ||
} | ||
} | ||
return newFrameElement; | ||
} | ||
async function updateFrame(frameSelector, getFrameElement) { | ||
const frame = document.querySelector(frameSelector); | ||
if (!frame) { | ||
throw new Error(`Frame '${frameSelector}' not found.`); | ||
} | ||
await abortPreviousUpdate(frame); | ||
const childFrames = [...document.querySelectorAll("hy-frame")].filter( | ||
(frame2) => frame2 !== frame2 && frame2.contains(frame2) | ||
); | ||
const abortController = new AbortController(); | ||
const newFramePromise = getFrameElement(frame, abortController.signal); | ||
pendingUpdates.set(frame, [abortController, newFramePromise]); | ||
reconcile(frame, await newFramePromise); | ||
await Promise.all(childFrames.map(abortPreviousUpdate)); | ||
} | ||
var parseHTML = (() => { | ||
const parser = new DOMParser(); | ||
return async (response) => { | ||
const contentType = response.headers.get("content-type") ?? "<unknown>"; | ||
if (!contentType.startsWith("text/html")) { | ||
throw new Error(`Expected content-type 'text/html' instead of '${contentType}'.`); | ||
} | ||
return parser.parseFromString(await response.text(), "text/html"); | ||
}; | ||
const parser = new DOMParser(); | ||
return async (response) => { | ||
const contentType = response.headers.get("content-type") ?? "<unknown>"; | ||
if (!contentType.startsWith("text/html")) { | ||
throw new Error(`Expected content-type 'text/html' instead of '${contentType}'.`); | ||
} | ||
return parser.parseFromString(await response.text(), "text/html"); | ||
}; | ||
})(); | ||
var pendingUpdates = /* @__PURE__ */ new WeakMap(); | ||
async function abortPreviousUpdate(frame) { | ||
const [previousSignal, previousPromise] = pendingUpdates.get(frame) ?? []; | ||
previousSignal?.abort(); | ||
try { | ||
await previousPromise; | ||
} catch (e) { | ||
if (e instanceof DOMException && e.name === "AbortError") { | ||
} else { | ||
log.warn(`An error occurred in an aborted frame update: ${e}`); | ||
// source/browser/form.browser.ts | ||
async function submitForm({ href, form, additionalData, frameId, updateHistory }) { | ||
let response = void 0; | ||
let submissionSuccessful = false; | ||
const formFrameId = getFormFrameId(form); | ||
await updateFrame(formFrameId, async (frame, signal) => { | ||
response = await fetchFrame(frame, href, { | ||
method: "POST", | ||
body: createFormRequestBody(form, additionalData), | ||
signal, | ||
}); | ||
submissionSuccessful = response.status < 300; | ||
return submissionSuccessful | ||
? void 0 | ||
: await extractFrameFromResponse(frame, response, signal); | ||
}); | ||
if (submissionSuccessful) { | ||
frameId ?? | ||
(frameId = | ||
document | ||
.getElementById(formFrameId) | ||
?.parentElement?.closest("hy-frame") | ||
?.getAttribute("id") ?? rootFrameId); | ||
await updateFrame(frameId, (frame, signal) => | ||
extractFrameFromResponse(frame, response, signal), | ||
); | ||
if (updateHistory ?? frameId === rootFrameId) { | ||
history.pushState(null, "", response.redirected ? response.url : href); | ||
} | ||
} | ||
} | ||
} | ||
function fetchRoute(frame, routeUrl, signal) { | ||
return fetchFrame(frame, routeUrl, { | ||
signal, | ||
headers: { "x-hytts": "true" } | ||
}); | ||
async function validateForm({ href, form }) { | ||
await updateFrame(getFormFrameId(form), async (frame, signal) => | ||
extractFrameFromResponse( | ||
frame, | ||
await fetchFrame(frame, href, { | ||
method: "POST", | ||
body: createFormRequestBody(form), | ||
signal, | ||
headers: { "x-hy-validate-form": "true" }, | ||
}), | ||
signal, | ||
), | ||
); | ||
} | ||
function fetchAction(frame, actionUrl, actionParams, signal) { | ||
return fetchFrame(frame, actionUrl, { | ||
method: "post", | ||
body: actionParams, | ||
signal, | ||
headers: { | ||
"content-type": "application/x-www-form-urlencoded", | ||
// see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers | ||
"x-hytts": "true" | ||
function interceptForms() { | ||
document.addEventListener("submit", (e) => { | ||
const [form, options] = getForm(e); | ||
if (!form || !options) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
void submitForm({ | ||
href: form.action, | ||
form, | ||
frameId: options.hyFrame, | ||
}); | ||
}); | ||
document.addEventListener("change", (e) => { | ||
const [form, options] = getForm(e); | ||
if (!form || !options?.hyValidate) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
void validateForm({ | ||
href: options.hyValidate, | ||
form, | ||
}); | ||
}); | ||
function getForm(e) { | ||
const form = | ||
e.target instanceof HTMLFormElement | ||
? e.target | ||
: e.target && "form" in e.target && e.target.form instanceof HTMLFormElement | ||
? e.target.form | ||
: void 0; | ||
if (e.defaultPrevented || !form) { | ||
return [void 0, void 0]; | ||
} | ||
const options = form.dataset; | ||
if (form.method.toUpperCase() !== "POST" || !form.action || !options?.hyValidate) { | ||
return [void 0, void 0]; | ||
} | ||
return [form, options]; | ||
} | ||
}); | ||
} | ||
async function fetchFrame(frame, url, fetchOptions) { | ||
try { | ||
return await fetch(url, fetchOptions); | ||
} catch (e) { | ||
if (e instanceof TypeError) { | ||
frame.dispatchEvent(new Event("hy:offline", { bubbles: true })); | ||
function createFormRequestBody(form, additionalData) { | ||
const params = new URLSearchParams(); | ||
for (const [name, value] of new FormData(form)) { | ||
params.append("$form." + name, value); | ||
} | ||
throw e; | ||
} | ||
return params.toString() + (additionalData ? `"&${additionalData}` : ""); | ||
} | ||
async function extractFrameFromResponse(frame, response) { | ||
const document2 = await parseHTML(response); | ||
const selector = frame instanceof HTMLBodyElement ? "body" : `#${frame.id}`; | ||
const newFrameElement = document2.querySelector(selector); | ||
if (!newFrameElement) { | ||
const updateFrameForErrorResponses = frame.dispatchEvent( | ||
new CustomEvent("hy:frame-missing", { | ||
bubbles: true, | ||
cancelable: true, | ||
detail: { response, newFrameElement } | ||
}) | ||
); | ||
if (updateFrameForErrorResponses && response.status >= 300) { | ||
const newFrame = frame.cloneNode(); | ||
newFrame.replaceChildren(...document2.body.children); | ||
return newFrame; | ||
} else { | ||
throw new Error(`Frame '${selector}' not found in the server's response.`); | ||
} | ||
} | ||
return newFrameElement; | ||
function getFormFrameId(form) { | ||
return `${form.getAttribute("id")}@frame`; | ||
} | ||
var navigationId = 0; | ||
var navigationIdKey = "hyNavigationId"; | ||
// source/browser/navigation.browser.ts | ||
async function navigateTo({ href, httpMethod, bodyParams, updateHistory, historyHref, frameId }) { | ||
updateHistory ?? (updateHistory = frameId === rootFrameId); | ||
let response = void 0; | ||
await updateFrame(frameId, async (frame, signal) => { | ||
response = await fetchFrame(frame, href, { | ||
method: httpMethod, | ||
body: httpMethod === "GET" ? void 0 : bodyParams, | ||
signal, | ||
}); | ||
return await extractFrameFromResponse(frame, response, signal); | ||
}); | ||
if (updateHistory) { | ||
history.pushState(null, "", historyHref ?? (response.redirected ? response.url : href)); | ||
} | ||
} | ||
function interceptClicks() { | ||
window.onclick = (e) => { | ||
const target = e.target?.closest("a") ?? e.target?.closest("button"); | ||
const options = target?.dataset; | ||
if (!target || !options || !options.hyNavigate) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
const navigationKind = options.hyNavigate; | ||
const href = (target instanceof HTMLAnchorElement ? target.href : void 0) ?? options.hyUrl; | ||
if (!href || !options.hyFrame) { | ||
throw new Error("Unknown navigation URL or target frame."); | ||
} | ||
switch (navigationKind) { | ||
case "route": | ||
(options.hyUpdateHistory ?? options.hyFrame === "body" ? navigateToRoute : loadRoute)(options.hyFrame, href); | ||
break; | ||
case "action": | ||
if (options.hyHistoryUrl) { | ||
navigateToAction( | ||
options.hyFrame, | ||
href, | ||
options.hyActionParams ?? "", | ||
options.hyHistoryUrl | ||
); | ||
} else { | ||
executeAction(options.hyFrame, href, options.hyActionParams ?? ""); | ||
document.addEventListener("click", (e) => { | ||
if (e.defaultPrevented) { | ||
return; | ||
} | ||
break; | ||
case void 0: | ||
break; | ||
default: { | ||
const switchGuard = navigationKind; | ||
throw new Error(`Unknown 'data-hy-navigate' value '${navigationKind}'.`); | ||
} | ||
} | ||
}; | ||
const target = e.target.closest("a") ?? e.target.closest("button"); | ||
const options = target?.dataset; | ||
if (!target || !options?.hyMethod) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
const method = options.hyMethod; | ||
const href = (target instanceof HTMLAnchorElement ? target.href : void 0) ?? options.hyUrl; | ||
if (!href) { | ||
throw new Error("Unknown navigation URL."); | ||
} | ||
if (!options.hyFrame) { | ||
throw new Error("Unknown target frame."); | ||
} | ||
switch (method) { | ||
case "GET": | ||
void navigateTo({ | ||
frameId: options.hyFrame, | ||
href, | ||
httpMethod: "GET", | ||
updateHistory: | ||
// We have to explicitly pass through the `undefined` so that the root frame | ||
// logic kicks in. | ||
options.hyUpdateHistory === void 0 ? void 0 : !!options.hyUpdateHistory, | ||
}); | ||
break; | ||
case "POST": { | ||
let bodyParams = options.hyBody; | ||
if (options.hyForm) { | ||
const form = document.getElementById(options.hyForm); | ||
if (!form || !(form instanceof HTMLFormElement)) { | ||
throw new Error(`Unable to find form '${options.hyForm}'.`); | ||
} | ||
bodyParams = createFormRequestBody(form, bodyParams); | ||
} | ||
void navigateTo({ | ||
frameId: options.hyFrame, | ||
href, | ||
httpMethod: "POST", | ||
bodyParams, | ||
updateHistory: !!options.hyUpdateHistory, | ||
historyHref: options.hyUpdateHistory, | ||
}); | ||
break; | ||
} | ||
case void 0: | ||
break; | ||
default: { | ||
const switchGuard = method; | ||
throw new Error(`Unknown or unsupported HTTP request method '${method}'.`); | ||
} | ||
} | ||
}); | ||
} | ||
function interceptHistoryChanges() { | ||
window.onpopstate = () => loadRoute("body", location.href); | ||
window.addEventListener( | ||
"popstate", | ||
() => void navigateTo({ frameId: rootFrameId, href: location.href, httpMethod: "GET" }), | ||
); | ||
} | ||
@@ -324,10 +451,10 @@ | ||
interceptClicks(); | ||
interceptForms(); | ||
interceptHistoryChanges(); | ||
var externalApi = { | ||
executeAction, | ||
loadRoute, | ||
navigateToAction, | ||
navigateToRoute, | ||
updateFrame | ||
navigateTo, | ||
updateFrame, | ||
addEventListener, | ||
submitForm, | ||
}; | ||
window.hy = externalApi; |
{ | ||
"name": "@hytts/hytts", | ||
"version": "0.1.0", | ||
"version": "0.2.3", | ||
"author": "Axel Habermaier", | ||
"license": "MIT", | ||
"homepage": "https://github.com/HyTTS/HyTTS", | ||
"description": "HyTTS (pronounced 'heights') is a framework for server-side rendered web apps based on TypeScript. ", | ||
"description": "HyTTS (pronounced \"heights\") is a JSX-based full-stack framework with end-to-end type safety for server-side rendered web apps, inspired by Turbo and htmx. ", | ||
"keywords": [ | ||
@@ -12,3 +12,5 @@ "TypeScript", | ||
"server-side rendering", | ||
"JSX" | ||
"JSX", | ||
"type safety", | ||
"type-safe routing" | ||
], | ||
@@ -28,6 +30,5 @@ "repository": { | ||
"@js-joda/core": "5.5.3", | ||
"express": "4.18.2", | ||
"lodash": "4.17.21", | ||
"qs": "6.11.2", | ||
"zod": "3.21.4" | ||
"zod": "3.22.3" | ||
}, | ||
@@ -34,0 +35,0 @@ "engines": { |
# HyperText TypeScript (HyTTS) | ||
HyTTS (pronounced "heights") is a framework for server-side rendered web apps based on TypeScript. | ||
It is inspired by [React](https://reactjs.org/) and its component-oriented, declarative nature, but uses server-side rendering instead, with SPA-like interactivity based on concepts found in [Turbo](https://hotwired.dev/) and [htmx](https://htmx.org/). | ||
HyTTS (pronounced "heights") is a full-stack web framework for server-side rendered web apps written in TypeScript. | ||
End-to-end type safety from server code to browser code and back is one of its major design principles. | ||
While HyTTS is heavily inspired by [React](https://reactjs.org/) and its JSX-based, component-oriented and declarative nature, it exclusively uses server-side rendering instead, with SPA-like interactivity based on concepts found in [Turbo](https://hotwired.dev/) and [htmx](https://htmx.org/). | ||
@@ -10,3 +11,5 @@ HyTTS' goal is to reduce the complexity of modern-day web development while retaining the user and developer experience improvements achieved by the web development community in recent years. | ||
HyTTS is currently under active development. | ||
Things might change considerably in an effort to enhance the features set and to reduce the complexity of HyTTS' API and implementation to a minimum. | ||
HyTTS is currently under active development after having completed a successful experimentation and prototyping phase. | ||
In the coming months, things might change considerably in an effort to enhance the feature set and to reduce the complexity of HyTTS' API and implementation. | ||
Thus, HyTTS is not yet ready for production use and does not have any documentation yet. |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
206514
4
5146
15
+ Addedzod@3.22.3(transitive)
- Removedexpress@4.18.2
- Removedaccepts@1.3.8(transitive)
- Removedarray-flatten@1.1.1(transitive)
- Removedbody-parser@1.20.1(transitive)
- Removedbytes@3.1.2(transitive)
- Removedcontent-disposition@0.5.4(transitive)
- Removedcontent-type@1.0.5(transitive)
- Removedcookie@0.5.0(transitive)
- Removedcookie-signature@1.0.6(transitive)
- Removeddebug@2.6.9(transitive)
- Removeddepd@2.0.0(transitive)
- Removeddestroy@1.2.0(transitive)
- Removedee-first@1.1.1(transitive)
- Removedencodeurl@1.0.2(transitive)
- Removedescape-html@1.0.3(transitive)
- Removedetag@1.8.1(transitive)
- Removedexpress@4.18.2(transitive)
- Removedfinalhandler@1.2.0(transitive)
- Removedforwarded@0.2.0(transitive)
- Removedfresh@0.5.2(transitive)
- Removedhttp-errors@2.0.0(transitive)
- Removediconv-lite@0.4.24(transitive)
- Removedinherits@2.0.4(transitive)
- Removedipaddr.js@1.9.1(transitive)
- Removedmedia-typer@0.3.0(transitive)
- Removedmerge-descriptors@1.0.1(transitive)
- Removedmethods@1.1.2(transitive)
- Removedmime@1.6.0(transitive)
- Removedmime-db@1.52.0(transitive)
- Removedmime-types@2.1.35(transitive)
- Removedms@2.0.02.1.3(transitive)
- Removednegotiator@0.6.3(transitive)
- Removedon-finished@2.4.1(transitive)
- Removedparseurl@1.3.3(transitive)
- Removedpath-to-regexp@0.1.7(transitive)
- Removedproxy-addr@2.0.7(transitive)
- Removedqs@6.11.0(transitive)
- Removedrange-parser@1.2.1(transitive)
- Removedraw-body@2.5.1(transitive)
- Removedsafe-buffer@5.2.1(transitive)
- Removedsafer-buffer@2.1.2(transitive)
- Removedsend@0.18.0(transitive)
- Removedserve-static@1.15.0(transitive)
- Removedsetprototypeof@1.2.0(transitive)
- Removedstatuses@2.0.1(transitive)
- Removedtoidentifier@1.0.1(transitive)
- Removedtype-is@1.6.18(transitive)
- Removedunpipe@1.0.0(transitive)
- Removedutils-merge@1.0.1(transitive)
- Removedvary@1.1.2(transitive)
- Removedzod@3.21.4(transitive)
Updatedzod@3.22.3