Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] feat: Hono Action #3973

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -121,4 +122,4 @@
"src/**/*.test.tsx"
]
}
}
}
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,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",
Expand Down Expand Up @@ -470,6 +475,9 @@
"css": [
"./dist/types/helper/css"
],
"action": [
"./dist/types/jsx/action/index.d.ts"
],
"jsx": [
"./dist/types/jsx"
],
Expand Down
6 changes: 3 additions & 3 deletions src/hono-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type {
Schema,
} from './types'
import { COMPOSED_HANDLER } from './utils/constants'
import { getPath, getPathNoStrict, mergePath } from './utils/url'
import { getPath, getPathNoStrict, mergePath, getRoutePath } from './utils/url'

const notFoundHandler = (c: Context) => {
return c.text('404 Not Found', 404)
Expand Down Expand Up @@ -372,8 +372,8 @@ class Hono<E extends Env = Env, S extends Schema = {}, BasePath extends string =
#addRoute(method: string, path: string, handler: H) {
method = method.toUpperCase()
path = mergePath(this._basePath, path)
const r: RouterRoute = { path, method, handler }
this.router.add(method, path, [handler, r])
const r: RouterRoute = { path: path, method: method, handler: handler }
this.router.add(method, getRoutePath(path), [handler, r])
this.routes.push(r)
}

Expand Down
106 changes: 106 additions & 0 deletions src/jsx/action/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
export default function client() {
const init = () => {
document
.querySelectorAll<HTMLFormElement | HTMLInputElement>(
'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
}

Check warning on line 12 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L1-L12

Added lines #L1 - L12 were not covered by tests

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
}

Check warning on line 26 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L14-L26

Added lines #L14 - L26 were not covered by tests

ev.preventDefault()

Check warning on line 28 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L28

Added line #L28 was not covered by tests

const props = startNode.nodeValue?.split('props:')[1] || '{}'

Check warning on line 30 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L30

Added line #L30 was not covered by tests

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',
'X-Hono-Action-Props': props,
},
})

Check warning on line 43 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L32-L43

Added lines #L32 - L43 were not covered by tests

if (response.headers.get('X-Hono-Action-Redirect')) {
return (window.location.href = response.headers.get('X-Hono-Action-Redirect')!)
}

Check warning on line 47 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L45-L47

Added lines #L45 - L47 were not covered by tests

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
}

Check warning on line 59 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L49-L59

Added lines #L49 - L59 were not covered by tests

// FIXME: Replace only the difference
if (!removed) {
for (
let node: ChildNode | null | undefined = startNode.nextSibling;
node !== endNode;

Check warning on line 65 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L62-L65

Added lines #L62 - L65 were not covered by tests

) {
const next: ChildNode | null | undefined = node?.nextSibling
node?.parentNode?.removeChild(node)
node = next
}
removed = true
}

Check warning on line 73 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L67-L73

Added lines #L67 - L73 were not covered by tests

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)
})
}

Check warning on line 88 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L75-L88

Added lines #L75 - L88 were not covered by tests

form.removeAttribute('data-hono-disabled')
}

Check warning on line 91 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L90-L91

Added lines #L90 - L91 were not covered by tests

if (el instanceof HTMLFormElement) {
form.addEventListener('submit', handler)
} else {
form.addEventListener('click', handler)
}
})
}

Check warning on line 99 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L93-L99

Added lines #L93 - L99 were not covered by tests

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
}

Check warning on line 106 in src/jsx/action/client.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/client.ts#L101-L106

Added lines #L101 - L106 were not covered by tests
138 changes: 138 additions & 0 deletions src/jsx/action/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { createHash } from 'node:crypto'

Check warning on line 1 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L1

Added line #L1 was not covered by tests
import type { Context, Hono } from '../..'
import { useRequestContext } from '../../middleware/jsx-renderer'
import type { BlankEnv } from '../../types'
import { raw } from '../../utils/html'
import type { HtmlEscapedString } from '../../utils/html'
import { absolutePath } from '../../utils/url'
import { jsxFn, Fragment } from '../base'
import type { Props } from '../base'
import { PERMALINK } from '../constants'
import { renderToReadableStream } from '../streaming'
import type { FC } from '../types'
import client from './client'

interface ActionHandler<Env extends BlankEnv> {
(data: Record<string, any> | undefined, c: Context<Env>, props: Props | undefined):

Check warning on line 16 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
| HtmlEscapedString
| Promise<HtmlEscapedString>
| Response
| Promise<Response>
}

type ActionReturn = [(key: string) => () => void, FC]

const clientScript = `(${client.toString()})()`
const clientScriptUrl = `/hono-action-${createHash('sha256').update(clientScript).digest('hex')}.js`

Check warning on line 26 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L25-L26

Added lines #L25 - L26 were not covered by tests

export const createAction = <Env extends BlankEnv>(
app: Hono<Env>,
handler: ActionHandler<Env>
): ActionReturn => {
const name = `/hono-action-${createHash('sha256').update(handler.toString()).digest('hex')}`

Check warning on line 32 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L28-L32

Added lines #L28 - L32 were not covered by tests

app.post(`${name}/:key`, async (c) => {
if (!c.req.header('X-Hono-Action')) {
return c.json({ error: 'Not a Hono Action' }, 400)
}

Check warning on line 37 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L34-L37

Added lines #L34 - L37 were not covered by tests

const props = JSON.parse(c.req.header('X-Hono-Action-Props') || '{}')
const data = await c.req.parseBody()
const res = await handler(data, c, props)
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), {
headers: {
'Content-Type': 'text/html; charset=UTF-8',
'Transfer-Encoding': 'chunked',
},
})
}
})

Check warning on line 59 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L39-L59

Added lines #L39 - L59 were not covered by tests

// FIXME: dedupe
app.get(
absolutePath(clientScriptUrl),
() =>
new Response(clientScript, {
headers: { 'Content-Type': 'application/javascript' },
})
)

Check warning on line 68 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L62-L68

Added lines #L62 - L68 were not covered by tests

const permalinkGenerator = (key: string) => {
let actionName: string | undefined
const subAction = () => {
if (!actionName) {
app.routes.forEach(({ path }) => {
if (path.includes(name)) {
actionName = path.replace(':key', key)
}
})
}
return actionName
}
;(subAction as any)['key'] = key

Check warning on line 82 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
return subAction
}

Check warning on line 84 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L70-L84

Added lines #L70 - L84 were not covered by tests

const action = (key: string) => {
const a = () => {}
;(a as any)[PERMALINK] = permalinkGenerator(key)

Check warning on line 88 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
return a
}
;(action as any)[PERMALINK] = permalinkGenerator('default')

Check warning on line 91 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type

Check warning on line 91 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L86-L91

Added lines #L86 - L91 were not covered by tests

return [
action,
async (props: Props = {}) => {
const subAction = props.action || action
const key = (subAction as any)[PERMALINK]['key']

Check warning on line 97 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type

Check warning on line 97 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L93-L97

Added lines #L93 - L97 were not covered by tests

const c = useRequestContext()
const res = await handler(undefined, c, props)
if (res instanceof Response) {
throw new Error('Response is not supported in JSX')
}
return Fragment({
children: [

Check warning on line 105 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L99-L105

Added lines #L99 - L105 were not covered by tests
// TBD: load client library, Might be simpler to make it globally referenceable and read from CDN
jsxFn(
'script',
{ src: clientScriptUrl, async: true },
jsxFn(async () => '', {}, []) as any

Check warning on line 110 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
) as any,

Check warning on line 111 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
raw(
`<!-- ${name}/${key} props:${JSON.stringify(props)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')} -->`
),
res,
raw(`<!-- /${name}/${key} -->`),
],
})
},
]
}

Check warning on line 123 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L107-L123

Added lines #L107 - L123 were not covered by tests

export const createForm = <Env extends BlankEnv>(
app: Hono<Env>,
handler: ActionHandler<Env>
): [ActionReturn[1]] => {
const [action, Component] = createAction(app, handler)
const subAction = action(Math.random().toString(36).substring(2, 15))
return [
(props: Props = {}) => {
return jsxFn('form', { action: subAction }, [
jsxFn(Component as any, { ...props, action: subAction }, []) as any,

Check warning on line 134 in src/jsx/action/index.ts

View workflow job for this annotation

GitHub Actions / Main

Unexpected any. Specify a different type
]) as any
},
]
}

Check warning on line 138 in src/jsx/action/index.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/action/index.ts#L125-L138

Added lines #L125 - L138 were not covered by tests
2 changes: 1 addition & 1 deletion src/jsx/dom/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,6 @@ export const useActionState = <T>(
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]
}
7 changes: 4 additions & 3 deletions src/jsx/intrinsic-element/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@

if (string instanceof Promise) {
return string.then((resString) =>
raw(string, [
raw(resString, [

Check warning on line 97 in src/jsx/intrinsic-element/components.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/intrinsic-element/components.ts#L97

Added line #L97 was not covered by tests
...((resString as HtmlEscapedString).callbacks || []),
insertIntoHead(tag, resString, restProps, precedence),
])
Expand Down Expand Up @@ -172,7 +172,8 @@
}>
> = (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)
}
Expand All @@ -185,7 +186,7 @@
) => {
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)
}
Expand Down
12 changes: 12 additions & 0 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@
}`
}

export const absolutePath = <T extends string>(path: T): T => {
return `@@@${path}` as T
}

Check warning on line 164 in src/utils/url.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/url.ts#L164

Added line #L164 was not covered by tests

export const isAbsolutePath = (path: string) => {

Check failure on line 166 in src/utils/url.ts

View workflow job for this annotation

GitHub Actions / Checking if it's valid for JSR

missing explicit return type in the public API
return path.startsWith('@@@')
}

Check warning on line 168 in src/utils/url.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/url.ts#L168

Added line #L168 was not covered by tests

export const getRoutePath = (path: string) => {

Check failure on line 170 in src/utils/url.ts

View workflow job for this annotation

GitHub Actions / Checking if it's valid for JSR

missing explicit return type in the public API
return path.startsWith('@@@') ? path.slice(3) : path
}

export const checkOptionalParameter = (path: string): string[] | null => {
/*
If path is `/api/animals/:type?` it will return:
Expand Down
Loading