From ab70ded74be84f48905e51336faf1245095db43b Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 29 Jun 2024 15:12:24 +0900 Subject: [PATCH 01/13] feat: introduce jsx/action Co-authored-by: Yusuke Wada --- package.json | 8 +++ src/jsx/action/client.ts | 76 ++++++++++++++++++++ src/jsx/action/index.ts | 95 +++++++++++++++++++++++++ src/jsx/dom/hooks/index.ts | 2 +- src/jsx/intrinsic-element/components.ts | 7 +- 5 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 src/jsx/action/client.ts create mode 100644 src/jsx/action/index.ts diff --git a/package.json b/package.json index 631e9dc9b..e5c50bb60 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,11 @@ "import": "./dist/helper/css/index.js", "require": "./dist/cjs/helper/css/index.js" }, + "./action": { + "types": "./dist/types/jsx/action/index.d.ts", + "import": "./dist/jsx/action/index.js", + "require": "./dist/cjs/jsx/action/index.js" + }, "./jsx": { "types": "./dist/types/jsx/index.d.ts", "import": "./dist/jsx/index.js", @@ -415,6 +420,9 @@ "css": [ "./dist/types/helper/css" ], + "action": [ + "./dist/types/jsx/action/index.d.ts" + ], "jsx": [ "./dist/types/jsx" ], diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts new file mode 100644 index 000000000..77ebff927 --- /dev/null +++ b/src/jsx/action/client.ts @@ -0,0 +1,76 @@ +export default function client() { + document + .querySelectorAll( + 'form[action^="/hono-action-"], input[formaction^="/hono-action-"]' + ) + .forEach((el) => { + const form = el instanceof HTMLFormElement ? el : el.form + const action = el.getAttribute(el instanceof HTMLFormElement ? 'action' : 'formaction') + if (!form || !action) { + return + } + + const handler = async (ev: SubmitEvent | MouseEvent) => { + ev.preventDefault() + + if (form.getAttribute('data-hono-disabled')) { + form.setAttribute('data-hono-disabled', '1') + } + const formData = new FormData(form) + const response = await fetch(action, { + method: 'POST', + body: formData, + }) + + // FIXME: requested twice + if (response.redirected) { + return (window.location.href = response.url) + } + + const component = document.querySelector( + `hono-action[data-hono-action="${action}"]` + ) + let removed = false + if (component) { + const stream = response.body + if (!stream) { + return + } + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + if (!removed) { + component.innerHTML = '' + removed = true + } + + const decoder = new TextDecoder() + const chunk = decoder.decode(value, { stream: true }) + const parser = new DOMParser() + const doc = parser.parseFromString(chunk, 'text/html') + const newComponents = [...doc.head.childNodes, ...doc.body.childNodes] as HTMLElement[] + newComponents.forEach((newComponent) => { + if (newComponent.tagName === 'SCRIPT') { + const script = newComponent.innerHTML + newComponent = document.createElement('script') + newComponent.innerHTML = script + } + component.appendChild(newComponent) + }) + } + } + + form.removeAttribute('data-hono-disabled') + } + + if (el instanceof HTMLFormElement) { + form.addEventListener('submit', handler) + } else { + form.addEventListener('click', handler) + } + }) +} diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts new file mode 100644 index 000000000..d3dbd72d5 --- /dev/null +++ b/src/jsx/action/index.ts @@ -0,0 +1,95 @@ +import type { Context, Hono } from '../..' +import type { BlankEnv } from '../../types' +import type { FC } from '../types' +import { useRequestContext } from '../../middleware/jsx-renderer' +import type { HtmlEscapedString } from '../../utils/html' +import { renderToReadableStream } from '../streaming' +import { jsxFn, Fragment } from '../base' +import client from './client' +import { PERMALINK } from '../constants' + +interface ActionHandler { + (data: Record | undefined, c: Context): + | HtmlEscapedString + | Promise + | Response + | Promise +} + +type ActionReturn = [() => void, FC] + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const actionHandlerIndex = new WeakMap, number>() + +export const createAction = ( + app: Hono, + handler: ActionHandler +): ActionReturn => { + const index = actionHandlerIndex.get(app) || 0 + actionHandlerIndex.set(app, index + 1) + let name = handler.name + if (!name) { + name = `/hono-action-${index}` + } + + // FIXME: parentApp.route('/subdir', app) + app.post(name, async (c) => { + const data = await c.req.parseBody() + const res = await handler(data, c) + if (res instanceof Response) { + return res + } else { + return c.body(renderToReadableStream(res), { + headers: { + 'Content-Type': 'text/html; charset=UTF-8', + 'Transfer-Encoding': 'chunked', + }, + }) + } + }) + if (index === 0) { + app.get( + 'action.js', + () => + new Response(`(${client.toString()})()`, { + headers: { 'Content-Type': 'application/javascript' }, + }) + ) + } + + const action = () => {} + ;(action as any)[PERMALINK] = () => name + return [ + action, + async () => { + const c = useRequestContext() + const res = await handler(undefined, c) + if (res instanceof Response) { + throw new Error('Response is not supported in JSX') + } + return Fragment({ + children: [ + // TBD: load client library, Might be simpler to make it globally referenceable and read from CDN + jsxFn( + 'script', + { src: 'action.js', async: true }, + jsxFn(async () => '', {}, []) as any + ) as any, + jsxFn('hono-action', { 'data-hono-action': name }, [res]), + ], + }) + }, + ] +} + +export const createForm = ( + app: Hono, + handler: ActionHandler +): [ActionReturn[1]] => { + const [action, Component] = createAction(app, handler) + return [ + () => { + return jsxFn('form', { action }, [jsxFn(Component as any, {}, []) as any]) as any + }, + ] +} diff --git a/src/jsx/dom/hooks/index.ts b/src/jsx/dom/hooks/index.ts index d0102a234..cafeb9aa5 100644 --- a/src/jsx/dom/hooks/index.ts +++ b/src/jsx/dom/hooks/index.ts @@ -85,6 +85,6 @@ export const useActionState = ( setState(await fn(state, data)) } // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(actionState as any)[PERMALINK] = permalink + ;(actionState as any)[PERMALINK] = () => permalink return [state, actionState] } diff --git a/src/jsx/intrinsic-element/components.ts b/src/jsx/intrinsic-element/components.ts index 0180d5421..4e1ddf538 100644 --- a/src/jsx/intrinsic-element/components.ts +++ b/src/jsx/intrinsic-element/components.ts @@ -93,7 +93,7 @@ const documentMetadataTag = (tag: string, children: Child, props: Props, sort: b if (string instanceof Promise) { return string.then((resString) => - raw(string, [ + raw(resString, [ ...((resString as HtmlEscapedString).callbacks || []), insertIntoHead(tag, resString, restProps, precedence), ]) @@ -151,7 +151,8 @@ export const form: FC< }> > = (props) => { if (typeof props.action === 'function') { - props.action = PERMALINK in props.action ? (props.action[PERMALINK] as string) : undefined + props.action = + PERMALINK in props.action ? (props.action[PERMALINK] as () => string)() : undefined } return newJSXNode('form', props) } @@ -164,7 +165,7 @@ const formActionableElement = ( ) => { if (typeof props.formAction === 'function') { props.formAction = - PERMALINK in props.formAction ? (props.formAction[PERMALINK] as string) : undefined + PERMALINK in props.formAction ? (props.formAction[PERMALINK] as () => string)() : undefined } return newJSXNode(tag, props) } From c61e9c01311c1d4d2ba92a065c6f751e03c5c9f9 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 11:50:52 +0900 Subject: [PATCH 02/13] feat: Ensure scripts are loaded with top-level paths --- src/hono-base.ts | 4 ++-- src/jsx/action/index.ts | 26 ++++++++++++++------------ src/utils/url.ts | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/hono-base.ts b/src/hono-base.ts index ae0b78211..db7cfbb2b 100644 --- a/src/hono-base.ts +++ b/src/hono-base.ts @@ -26,7 +26,7 @@ import type { RouterRoute, Schema, } from './types' -import { getPath, getPathNoStrict, mergePath } from './utils/url' +import { getPath, getPathNoStrict, mergePath, getRoutePath } from './utils/url' /** * Symbol used to mark a composed handler. @@ -378,7 +378,7 @@ class Hono { (data: Record | undefined, c: Context): @@ -18,8 +20,8 @@ interface ActionHandler { type ActionReturn = [() => void, FC] -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const actionHandlerIndex = new WeakMap, number>() +const clientScript = `(${client.toString()})()` +const clientScriptUrl = `/hono-action-${createHash('sha256').update(clientScript).digest('hex')}.js` export const createAction = ( app: Hono, @@ -47,15 +49,15 @@ export const createAction = ( }) } }) - if (index === 0) { - app.get( - 'action.js', - () => - new Response(`(${client.toString()})()`, { - headers: { 'Content-Type': 'application/javascript' }, - }) - ) - } + + // FIXME: dedupe + app.get( + absolutePath(clientScriptUrl), + () => + new Response(clientScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) + ) const action = () => {} ;(action as any)[PERMALINK] = () => name @@ -72,7 +74,7 @@ export const createAction = ( // TBD: load client library, Might be simpler to make it globally referenceable and read from CDN jsxFn( 'script', - { src: 'action.js', async: true }, + { src: clientScriptUrl, async: true }, jsxFn(async () => '', {}, []) as any ) as any, jsxFn('hono-action', { 'data-hono-action': name }, [res]), diff --git a/src/utils/url.ts b/src/utils/url.ts index 47db1c691..ea4592e41 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -130,11 +130,28 @@ export const getPathNoStrict = (request: Request): string => { return result.length > 1 && result[result.length - 1] === '/' ? result.slice(0, -1) : result } +export const absolutePath = (path: T): T => { + return `@@@${path}` as T +} + +export const isAbsolutePath = (path: string) => { + return path.startsWith('@@@') +} + +export const getRoutePath = (path: string) => { + return path.startsWith('@@@') ? path.slice(3) : path +} + export const mergePath = (...paths: string[]): string => { let p: string = '' let endsWithSlash = false for (let path of paths) { + if (isAbsolutePath(path)) { + p = path + continue + } + /* ['/hey/','/say'] => ['/hey', '/say'] */ if (p[p.length - 1] === '/') { p = p.slice(0, -1) From 8a3570e28ae1aea5d6a5cf05b224b56a3b485e4a Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 11:52:55 +0900 Subject: [PATCH 03/13] feat: generate route path from hash value of client script --- src/jsx/action/index.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 0fbb38932..ae33f9f09 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -27,14 +27,8 @@ export const createAction = ( app: Hono, handler: ActionHandler ): ActionReturn => { - const index = actionHandlerIndex.get(app) || 0 - actionHandlerIndex.set(app, index + 1) - let name = handler.name - if (!name) { - name = `/hono-action-${index}` - } + const name = `/hono-action-${createHash('sha256').update(handler.toString()).digest('hex')}` - // FIXME: parentApp.route('/subdir', app) app.post(name, async (c) => { const data = await c.req.parseBody() const res = await handler(data, c) @@ -60,7 +54,18 @@ export const createAction = ( ) const action = () => {} - ;(action as any)[PERMALINK] = () => name + let actionName: string | undefined + ;(action as any)[PERMALINK] = () => { + if (!actionName) { + app.routes.forEach(({ path }) => { + if (path.includes(name)) { + actionName = path + } + }) + } + return actionName + } + return [ action, async () => { From 58eba0bf7ba9f9c6cde6feee73e4349c6649356e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 11:54:04 +0900 Subject: [PATCH 04/13] feat: CSRF protection for hono action --- src/jsx/action/client.ts | 3 +++ src/jsx/action/index.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index 77ebff927..cd3e4a128 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -20,6 +20,9 @@ export default function client() { const response = await fetch(action, { method: 'POST', body: formData, + headers: { + 'X-Hono-Action': 'true', + }, }) // FIXME: requested twice diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index ae33f9f09..87951a295 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -30,6 +30,10 @@ export const createAction = ( const name = `/hono-action-${createHash('sha256').update(handler.toString()).digest('hex')}` app.post(name, async (c) => { + if (!c.req.header('X-Hono-Action')) { + return c.json({ error: 'Not a Hono Action' }, 400) + } + const data = await c.req.parseBody() const res = await handler(data, c) if (res instanceof Response) { From f7c36375280572767501c303a85052e923dab66e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 11:54:41 +0900 Subject: [PATCH 05/13] feat: support redirect in hono action --- src/jsx/action/client.ts | 5 ++--- src/jsx/action/index.ts | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index cd3e4a128..8a191cd16 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -25,9 +25,8 @@ export default function client() { }, }) - // FIXME: requested twice - if (response.redirected) { - return (window.location.href = response.url) + if (response.headers.get('X-Hono-Action-Redirect')) { + return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!) } const component = document.querySelector( diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 87951a295..8f1bf65be 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -37,6 +37,13 @@ export const createAction = ( const data = await c.req.parseBody() const res = await handler(data, c) if (res instanceof Response) { + if (res.status > 300 && res.status < 400) { + return new Response('', { + headers: { + 'X-Hono-Action-Redirect': res.headers.get('Location') || '', + }, + }) + } return res } else { return c.body(renderToReadableStream(res), { From fe71d21e5aa982faa25544ac1d744a65ee61d265 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 11:55:19 +0900 Subject: [PATCH 06/13] docs: add fixme comment --- src/jsx/action/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index 8a191cd16..1ba5aed88 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -45,6 +45,7 @@ export default function client() { break } + // FIXME: Replace only the difference if (!removed) { component.innerHTML = '' removed = true From 2f9906e1c79f3e46afabedf88d3aaed0f2e49ddd Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 14:04:55 +0900 Subject: [PATCH 07/13] feat(hono/action): use comments instead of custom element --- src/jsx/action/client.ts | 78 ++++++++++++++++++++++++---------------- src/jsx/action/index.ts | 5 ++- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index 1ba5aed88..927858d49 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -29,42 +29,58 @@ export default function client() { return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!) } - const component = document.querySelector( - `hono-action[data-hono-action="${action}"]` - ) + const commentNodes = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT, { + acceptNode: (node) => { + return node.nodeValue?.includes(action) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT + }, + }) + const startNode = commentNodes.nextNode() + const endNode = commentNodes.nextNode() + if (!startNode || !endNode) { + return + } + let removed = false - if (component) { - const stream = response.body - if (!stream) { - return + const stream = response.body + if (!stream) { + return + } + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break } - const reader = stream.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - // FIXME: Replace only the difference - if (!removed) { - component.innerHTML = '' - removed = true - } + // FIXME: Replace only the difference + if (!removed) { + for ( + let node: ChildNode | null | undefined = startNode.nextSibling; + node !== endNode; - const decoder = new TextDecoder() - const chunk = decoder.decode(value, { stream: true }) - const parser = new DOMParser() - const doc = parser.parseFromString(chunk, 'text/html') - const newComponents = [...doc.head.childNodes, ...doc.body.childNodes] as HTMLElement[] - newComponents.forEach((newComponent) => { - if (newComponent.tagName === 'SCRIPT') { - const script = newComponent.innerHTML - newComponent = document.createElement('script') - newComponent.innerHTML = script - } - component.appendChild(newComponent) - }) + ) { + const next: ChildNode | null | undefined = node?.nextSibling + node?.parentNode?.removeChild(node) + node = next + } + removed = true } + + const decoder = new TextDecoder() + const chunk = decoder.decode(value, { stream: true }) + const parser = new DOMParser() + const doc = parser.parseFromString(chunk, 'text/html') + const newComponents = [...doc.head.childNodes, ...doc.body.childNodes] as HTMLElement[] + newComponents.forEach((newComponent) => { + if (newComponent.tagName === 'SCRIPT') { + const script = newComponent.innerHTML + newComponent = document.createElement('script') + newComponent.innerHTML = script + } + endNode.parentNode?.insertBefore(newComponent, endNode) + }) } form.removeAttribute('data-hono-disabled') diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 8f1bf65be..6388df546 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -2,6 +2,7 @@ import type { Context, Hono } from '../..' import type { BlankEnv } from '../../types' import type { FC } from '../types' import { useRequestContext } from '../../middleware/jsx-renderer' +import { raw } from '../../utils/html' import type { HtmlEscapedString } from '../../utils/html' import { renderToReadableStream } from '../streaming' import { jsxFn, Fragment } from '../base' @@ -93,7 +94,9 @@ export const createAction = ( { src: clientScriptUrl, async: true }, jsxFn(async () => '', {}, []) as any ) as any, - jsxFn('hono-action', { 'data-hono-action': name }, [res]), + raw(``), + res, + raw(``), ], }) }, From dc23ed891efa1b42a4f272beaf30d816e424498c Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 14:05:25 +0900 Subject: [PATCH 08/13] feat(hono/action): wait for DOMContentLoaded --- src/jsx/action/client.ts | 166 ++++++++++++++++++++------------------- 1 file changed, 87 insertions(+), 79 deletions(-) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index 927858d49..e72cc1e20 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -1,95 +1,103 @@ export default function client() { - document - .querySelectorAll( - 'form[action^="/hono-action-"], input[formaction^="/hono-action-"]' - ) - .forEach((el) => { - const form = el instanceof HTMLFormElement ? el : el.form - const action = el.getAttribute(el instanceof HTMLFormElement ? 'action' : 'formaction') - if (!form || !action) { - return - } + const init = () => { + document + .querySelectorAll( + 'form[action^="/hono-action-"], input[formaction^="/hono-action-"]' + ) + .forEach((el) => { + const form = el instanceof HTMLFormElement ? el : el.form + const action = el.getAttribute(el instanceof HTMLFormElement ? 'action' : 'formaction') + if (!form || !action) { + return + } - const handler = async (ev: SubmitEvent | MouseEvent) => { - ev.preventDefault() + const handler = async (ev: SubmitEvent | MouseEvent) => { + ev.preventDefault() - if (form.getAttribute('data-hono-disabled')) { - form.setAttribute('data-hono-disabled', '1') - } - const formData = new FormData(form) - const response = await fetch(action, { - method: 'POST', - body: formData, - headers: { - 'X-Hono-Action': 'true', - }, - }) + if (form.getAttribute('data-hono-disabled')) { + form.setAttribute('data-hono-disabled', '1') + } + const formData = new FormData(form) + const response = await fetch(action, { + method: 'POST', + body: formData, + headers: { + 'X-Hono-Action': 'true', + }, + }) - if (response.headers.get('X-Hono-Action-Redirect')) { - return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!) - } + if (response.headers.get('X-Hono-Action-Redirect')) { + return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!) + } - const commentNodes = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT, { - acceptNode: (node) => { - return node.nodeValue?.includes(action) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT - }, - }) - const startNode = commentNodes.nextNode() - const endNode = commentNodes.nextNode() - if (!startNode || !endNode) { - return - } + const commentNodes = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT, { + acceptNode: (node) => { + return node.nodeValue?.includes(action) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT + }, + }) + const startNode = commentNodes.nextNode() + const endNode = commentNodes.nextNode() + if (!startNode || !endNode) { + return + } - let removed = false - const stream = response.body - if (!stream) { - return - } - const reader = stream.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break + let removed = false + const stream = response.body + if (!stream) { + return } + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } - // FIXME: Replace only the difference - if (!removed) { - for ( - let node: ChildNode | null | undefined = startNode.nextSibling; - node !== endNode; + // FIXME: Replace only the difference + if (!removed) { + for ( + let node: ChildNode | null | undefined = startNode.nextSibling; + node !== endNode; - ) { - const next: ChildNode | null | undefined = node?.nextSibling - node?.parentNode?.removeChild(node) - node = next + ) { + const next: ChildNode | null | undefined = node?.nextSibling + node?.parentNode?.removeChild(node) + node = next + } + removed = true } - removed = true + + const decoder = new TextDecoder() + const chunk = decoder.decode(value, { stream: true }) + const parser = new DOMParser() + const doc = parser.parseFromString(chunk, 'text/html') + const newComponents = [...doc.head.childNodes, ...doc.body.childNodes] as HTMLElement[] + newComponents.forEach((newComponent) => { + if (newComponent.tagName === 'SCRIPT') { + const script = newComponent.innerHTML + newComponent = document.createElement('script') + newComponent.innerHTML = script + } + endNode.parentNode?.insertBefore(newComponent, endNode) + }) } - const decoder = new TextDecoder() - const chunk = decoder.decode(value, { stream: true }) - const parser = new DOMParser() - const doc = parser.parseFromString(chunk, 'text/html') - const newComponents = [...doc.head.childNodes, ...doc.body.childNodes] as HTMLElement[] - newComponents.forEach((newComponent) => { - if (newComponent.tagName === 'SCRIPT') { - const script = newComponent.innerHTML - newComponent = document.createElement('script') - newComponent.innerHTML = script - } - endNode.parentNode?.insertBefore(newComponent, endNode) - }) + form.removeAttribute('data-hono-disabled') } - form.removeAttribute('data-hono-disabled') - } + if (el instanceof HTMLFormElement) { + form.addEventListener('submit', handler) + } else { + form.addEventListener('click', handler) + } + }) + } - if (el instanceof HTMLFormElement) { - form.addEventListener('submit', handler) - } else { - form.addEventListener('click', handler) - } - }) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) + } else { + init() + } } From e61488e05c797926fb3f29d99872f29c885e6c9f Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 15:39:23 +0900 Subject: [PATCH 09/13] feat(hono/action): add key to action for multiple form submission --- src/jsx/action/index.ts | 42 ++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 6388df546..79dd01631 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -19,7 +19,7 @@ interface ActionHandler { | Promise } -type ActionReturn = [() => void, FC] +type ActionReturn = [(key?: string) => void, FC] const clientScript = `(${client.toString()})()` const clientScriptUrl = `/hono-action-${createHash('sha256').update(clientScript).digest('hex')}.js` @@ -30,11 +30,12 @@ export const createAction = ( ): ActionReturn => { const name = `/hono-action-${createHash('sha256').update(handler.toString()).digest('hex')}` - app.post(name, async (c) => { + app.post(`${name}/:key`, async (c) => { if (!c.req.header('X-Hono-Action')) { return c.json({ error: 'Not a Hono Action' }, 400) } + const key = c.req.param('key') const data = await c.req.parseBody() const res = await handler(data, c) if (res instanceof Response) { @@ -65,27 +66,31 @@ export const createAction = ( }) ) - const action = () => {} let actionName: string | undefined - ;(action as any)[PERMALINK] = () => { - if (!actionName) { - app.routes.forEach(({ path }) => { - if (path.includes(name)) { - actionName = path - } - }) + const action = (key?: string) => { + actionName = undefined + ;(action as any)[PERMALINK] = () => { + if (!actionName) { + app.routes.forEach(({ path }) => { + if (path.includes(name)) { + actionName = path.replace(':key', key) + } + }) + } + return actionName } - return actionName + return action } return [ - action, - async () => { + action('default'), + async (props?: Record) => { const c = useRequestContext() const res = await handler(undefined, c) if (res instanceof Response) { throw new Error('Response is not supported in JSX') } + const key = props?.key || 'default' return Fragment({ children: [ // TBD: load client library, Might be simpler to make it globally referenceable and read from CDN @@ -94,9 +99,9 @@ export const createAction = ( { src: clientScriptUrl, async: true }, jsxFn(async () => '', {}, []) as any ) as any, - raw(``), + raw(``), res, - raw(``), + raw(``), ], }) }, @@ -109,8 +114,11 @@ export const createForm = ( ): [ActionReturn[1]] => { const [action, Component] = createAction(app, handler) return [ - () => { - return jsxFn('form', { action }, [jsxFn(Component as any, {}, []) as any]) as any + (props) => { + const key = Math.random().toString(36).substring(2, 15) + return jsxFn('form', { ...props, action: action(key) }, [ + jsxFn(Component as any, { key }, []) as any, + ]) as any }, ] } From 9ddf24c1e94ece2282203f1d2ea6a4ee0d845dfc Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 16:00:27 +0900 Subject: [PATCH 10/13] feat(hono/action): pass props to action --- src/jsx/action/client.ts | 29 ++++++++++++++++------------- src/jsx/action/index.ts | 31 +++++++++++++++++++------------ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/jsx/action/client.ts b/src/jsx/action/client.ts index e72cc1e20..0253995c5 100644 --- a/src/jsx/action/client.ts +++ b/src/jsx/action/client.ts @@ -12,8 +12,23 @@ export default function client() { } const handler = async (ev: SubmitEvent | MouseEvent) => { + const commentNodes = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT, { + acceptNode: (node) => { + return node.nodeValue?.includes(action) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT + }, + }) + const startNode = commentNodes.nextNode() + const endNode = commentNodes.nextNode() + if (!startNode || !endNode) { + return + } + ev.preventDefault() + const props = startNode.nodeValue?.split('props:')[1] || '{}' + if (form.getAttribute('data-hono-disabled')) { form.setAttribute('data-hono-disabled', '1') } @@ -23,6 +38,7 @@ export default function client() { body: formData, headers: { 'X-Hono-Action': 'true', + 'X-Hono-Action-Props': props, }, }) @@ -30,19 +46,6 @@ export default function client() { return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!) } - const commentNodes = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT, { - acceptNode: (node) => { - return node.nodeValue?.includes(action) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT - }, - }) - const startNode = commentNodes.nextNode() - const endNode = commentNodes.nextNode() - if (!startNode || !endNode) { - return - } - let removed = false const stream = response.body if (!stream) { diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 79dd01631..6923f4e07 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -6,20 +6,21 @@ import { raw } from '../../utils/html' import type { HtmlEscapedString } from '../../utils/html' import { renderToReadableStream } from '../streaming' import { jsxFn, Fragment } from '../base' +import type { Props } from '../base' import client from './client' import { PERMALINK } from '../constants' import { absolutePath } from '../../utils/url' import { createHash } from 'node:crypto' interface ActionHandler { - (data: Record | undefined, c: Context): + (data: Record | undefined, c: Context, props: Props | undefined): | HtmlEscapedString | Promise | Response | Promise } -type ActionReturn = [(key?: string) => void, FC] +type ActionReturn = [(key: string) => void, FC] const clientScript = `(${client.toString()})()` const clientScriptUrl = `/hono-action-${createHash('sha256').update(clientScript).digest('hex')}.js` @@ -35,9 +36,9 @@ export const createAction = ( return c.json({ error: 'Not a Hono Action' }, 400) } - const key = c.req.param('key') + const props = JSON.parse(c.req.header('X-Hono-Action-Props') || '{}') const data = await c.req.parseBody() - const res = await handler(data, c) + const res = await handler(data, c, props) if (res instanceof Response) { if (res.status > 300 && res.status < 400) { return new Response('', { @@ -67,7 +68,7 @@ export const createAction = ( ) let actionName: string | undefined - const action = (key?: string) => { + const action = (key: string) => { actionName = undefined ;(action as any)[PERMALINK] = () => { if (!actionName) { @@ -84,13 +85,15 @@ export const createAction = ( return [ action('default'), - async (props?: Record) => { + async (props: Props = {}) => { + const key = props?.key || 'default' + delete props.key + const c = useRequestContext() - const res = await handler(undefined, c) + const res = await handler(undefined, c, props) if (res instanceof Response) { throw new Error('Response is not supported in JSX') } - const key = props?.key || 'default' return Fragment({ children: [ // TBD: load client library, Might be simpler to make it globally referenceable and read from CDN @@ -99,7 +102,11 @@ export const createAction = ( { src: clientScriptUrl, async: true }, jsxFn(async () => '', {}, []) as any ) as any, - raw(``), + raw( + `` + ), res, raw(``), ], @@ -114,10 +121,10 @@ export const createForm = ( ): [ActionReturn[1]] => { const [action, Component] = createAction(app, handler) return [ - (props) => { + (props: Props = {}) => { const key = Math.random().toString(36).substring(2, 15) - return jsxFn('form', { ...props, action: action(key) }, [ - jsxFn(Component as any, { key }, []) as any, + return jsxFn('form', { action: action(key) }, [ + jsxFn(Component as any, { ...props, key }, []) as any, ]) as any }, ] From 714e7781ccf48e53cdc7e7fbd21b931f1b614cdb Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 30 Jun 2024 16:32:34 +0900 Subject: [PATCH 11/13] refactor(hono/action): refactor action key --- src/jsx/action/index.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index 6923f4e07..b53fa40b0 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -20,7 +20,7 @@ interface ActionHandler { | Promise } -type ActionReturn = [(key: string) => void, FC] +type ActionReturn = [(key: string) => () => void, FC] const clientScript = `(${client.toString()})()` const clientScriptUrl = `/hono-action-${createHash('sha256').update(clientScript).digest('hex')}.js` @@ -67,10 +67,9 @@ export const createAction = ( }) ) - let actionName: string | undefined - const action = (key: string) => { - actionName = undefined - ;(action as any)[PERMALINK] = () => { + const permalinkGenerator = (key: string) => { + let actionName: string | undefined + const subAction = () => { if (!actionName) { app.routes.forEach(({ path }) => { if (path.includes(name)) { @@ -80,14 +79,22 @@ export const createAction = ( } return actionName } - return action + ;(subAction as any)['key'] = key + return subAction + } + + const action = (key: string) => { + const a = () => {} + ;(a as any)[PERMALINK] = permalinkGenerator(key) + return a } + ;(action as any)[PERMALINK] = permalinkGenerator('default') return [ - action('default'), + action, async (props: Props = {}) => { - const key = props?.key || 'default' - delete props.key + const subAction = props.action || action + const key = (subAction as any)[PERMALINK]['key'] const c = useRequestContext() const res = await handler(undefined, c, props) @@ -120,11 +127,11 @@ export const createForm = ( handler: ActionHandler ): [ActionReturn[1]] => { const [action, Component] = createAction(app, handler) + const subAction = action(Math.random().toString(36).substring(2, 15)) return [ (props: Props = {}) => { - const key = Math.random().toString(36).substring(2, 15) - return jsxFn('form', { action: action(key) }, [ - jsxFn(Component as any, { ...props, key }, []) as any, + return jsxFn('form', { action: subAction }, [ + jsxFn(Component as any, { ...props, action: subAction }, []) as any, ]) as any }, ] From 500c3b9f2db4349a92c34b10f18e0076edd4c658 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 4 Mar 2025 15:41:20 +0900 Subject: [PATCH 12/13] chore: update `jsr.json` --- jsr.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jsr.json b/jsr.json index b60085b5d..bda7b9bf1 100644 --- a/jsr.json +++ b/jsr.json @@ -34,6 +34,7 @@ "./trailing-slash": "./src/middleware/trailing-slash/index.ts", "./html": "./src/helper/html/index.ts", "./css": "./src/helper/css/index.ts", + "./action": "./src/jsx/action/index.ts", "./jsx": "./src/jsx/index.ts", "./jsx/jsx-dev-runtime": "./src/jsx/jsx-dev-runtime.ts", "./jsx/jsx-runtime": "./src/jsx/jsx-runtime.ts", @@ -121,4 +122,4 @@ "src/**/*.test.tsx" ] } -} +} \ No newline at end of file From 71cdc6abc118150d7f28660a9604ef8887295a5b Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 4 Mar 2025 15:45:12 +0900 Subject: [PATCH 13/13] chore: lint and format fix --- src/jsx/action/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/jsx/action/index.ts b/src/jsx/action/index.ts index b53fa40b0..0c35c66fa 100644 --- a/src/jsx/action/index.ts +++ b/src/jsx/action/index.ts @@ -1,16 +1,16 @@ +import { createHash } from 'node:crypto' import type { Context, Hono } from '../..' -import type { BlankEnv } from '../../types' -import type { FC } from '../types' import { useRequestContext } from '../../middleware/jsx-renderer' +import type { BlankEnv } from '../../types' import { raw } from '../../utils/html' import type { HtmlEscapedString } from '../../utils/html' -import { renderToReadableStream } from '../streaming' +import { absolutePath } from '../../utils/url' import { jsxFn, Fragment } from '../base' import type { Props } from '../base' -import client from './client' import { PERMALINK } from '../constants' -import { absolutePath } from '../../utils/url' -import { createHash } from 'node:crypto' +import { renderToReadableStream } from '../streaming' +import type { FC } from '../types' +import client from './client' interface ActionHandler { (data: Record | undefined, c: Context, props: Props | undefined):