feat: introduce React Renderer Middleware (#309)
* feat: introduce React Renderer Middleware * docs: update readme * chore: add changeset * use `global.d.ts` * remove global.d.ts * import types correctlypull/314/head
parent
ee12e3c08a
commit
9348fa2663
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/react-renderer': patch
|
||||
---
|
||||
|
||||
feat: introduce React Renderer
|
|
@ -0,0 +1,224 @@
|
|||
# React Renderer Middleware
|
||||
|
||||
React Renderer Middleware allows for the easy creation of a renderer based on React for Hono.
|
||||
|
||||
## Installation
|
||||
|
||||
```txt
|
||||
npm i @hono/react-renderer react react-dom hono
|
||||
npm i -D @types/react @types/react-dom
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
`tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```tsx
|
||||
import { Hono } from 'hono'
|
||||
import { reactRenderer } from '@hono/react-renderer'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
reactRenderer(({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<h1>React + Hono</h1>
|
||||
<div>{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.render(<p>Welcome!</p>)
|
||||
})
|
||||
```
|
||||
|
||||
### Extending `Props`
|
||||
|
||||
You can define types of `Props`:
|
||||
|
||||
```tsx
|
||||
declare module '@hono/react-renderer' {
|
||||
interface Props {
|
||||
title: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, you can use it in the `reactRenderer()` function and pass values as a second argument to `c.render()`:
|
||||
|
||||
```tsx
|
||||
app.get(
|
||||
'*',
|
||||
reactRenderer(({ children, title }) => {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.render(<p>Welcome!</p>, {
|
||||
title: 'Top Page',
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### `useRequestContext()`
|
||||
|
||||
You can get an instance of `Context` in a function component:
|
||||
|
||||
```tsx
|
||||
const Component = () => {
|
||||
const c = useRequestContext()
|
||||
return <p>You access {c.req.url}</p>
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.render(<Component />)
|
||||
})
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `docType`
|
||||
|
||||
If you set it `true`, `DOCTYPE` will be added:
|
||||
|
||||
```tsx
|
||||
app.get(
|
||||
'*',
|
||||
reactRenderer(
|
||||
({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div>{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
},
|
||||
{
|
||||
docType: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
The HTML is the following:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div><p>Welcome!</p></div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
You can specify the `docType` as you like.
|
||||
|
||||
```tsx
|
||||
app.get(
|
||||
'*',
|
||||
reactRenderer(
|
||||
({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div>{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
},
|
||||
{
|
||||
docType:
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### `stream`
|
||||
|
||||
It enables returning a streaming response. You can use a `Suspense` with it:
|
||||
|
||||
```tsx
|
||||
import { reactRenderer } from '@hono/react-renderer'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
reactRenderer(
|
||||
({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div>{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
},
|
||||
{
|
||||
stream: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
let done = false
|
||||
|
||||
const Component = () => {
|
||||
if (done) {
|
||||
return <p>Done!</p>
|
||||
}
|
||||
throw new Promise((resolve) => {
|
||||
done = true
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.render(
|
||||
<Suspense fallback='loading...'>
|
||||
<Component />
|
||||
</Suspense>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## Limitation
|
||||
|
||||
A streaming feature is not available on Vite or Vitest.
|
||||
|
||||
## Author
|
||||
|
||||
Yusuke Wada <https://github.com/yusukebe>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@hono/react-renderer",
|
||||
"version": "0.0.0",
|
||||
"description": "React Renderer Middleware for Hono",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"build": "tsup ./src/index.ts --external hono,react,react-dom --format esm,cjs --dts",
|
||||
"publint": "publint"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/honojs/middleware.git"
|
||||
},
|
||||
"homepage": "https://github.com/honojs/middleware",
|
||||
"peerDependencies": {
|
||||
"hono": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"hono": "^3.11.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tsup": "^8.0.1",
|
||||
"vitest": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export * from './react-renderer'
|
||||
|
||||
export type Props = {}
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextRenderer {
|
||||
(children: React.ReactElement, props?: Props): Response | Promise<Response>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import type { Context } from 'hono'
|
||||
import type { Env, MiddlewareHandler } from 'hono/types'
|
||||
import React from 'react'
|
||||
import { renderToString, renderToReadableStream } from 'react-dom/server'
|
||||
import type { Props } from '.'
|
||||
|
||||
type RendererOptions = {
|
||||
docType?: boolean | string
|
||||
stream?: boolean | Record<string, string>
|
||||
}
|
||||
|
||||
type BaseProps = {
|
||||
c: Context
|
||||
children: React.ReactElement
|
||||
}
|
||||
|
||||
const RequestContext = React.createContext<Context | null>(null)
|
||||
|
||||
const createRenderer =
|
||||
(c: Context, component?: React.FC<Props & BaseProps>, options?: RendererOptions) =>
|
||||
async (children: React.ReactElement, props?: Props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const node = component ? component({ children, c, ...props }) : children
|
||||
|
||||
if (options?.stream) {
|
||||
const stream = await renderToReadableStream(
|
||||
React.createElement(RequestContext.Provider, { value: c }, node)
|
||||
)
|
||||
return c.body(stream, {
|
||||
headers:
|
||||
options.stream === true
|
||||
? {
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Content-Type': 'text/html; charset=UTF-8',
|
||||
}
|
||||
: options.stream,
|
||||
})
|
||||
} else {
|
||||
const docType =
|
||||
typeof options?.docType === 'string'
|
||||
? options.docType
|
||||
: options?.docType === true
|
||||
? '<!DOCTYPE html>'
|
||||
: ''
|
||||
const body =
|
||||
docType + renderToString(React.createElement(RequestContext.Provider, { value: c }, node))
|
||||
return c.html(body)
|
||||
}
|
||||
}
|
||||
|
||||
export const reactRenderer = (
|
||||
component?: React.FC<Props & BaseProps>,
|
||||
options?: RendererOptions
|
||||
): MiddlewareHandler =>
|
||||
function reactRenderer(c, next) {
|
||||
c.setRenderer(createRenderer(c, component, options))
|
||||
return next()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const useRequestContext = <E extends Env = any>(): Context<E> => {
|
||||
const c = React.useContext(RequestContext)
|
||||
if (!c) {
|
||||
throw new Error('RequestContext is not provided.')
|
||||
}
|
||||
return c
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { Hono } from 'hono'
|
||||
import { reactRenderer, useRequestContext } from '../src/react-renderer'
|
||||
|
||||
const RequestUrl = () => {
|
||||
const c = useRequestContext()
|
||||
return <>{c.req.url}</>
|
||||
}
|
||||
|
||||
describe('Basic', () => {
|
||||
const app = new Hono()
|
||||
app.use(
|
||||
// @ts-expect-error - `title` is not defined
|
||||
reactRenderer(({ children, title }) => {
|
||||
return (
|
||||
<html>
|
||||
<head>{title}</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
})
|
||||
)
|
||||
app.get('/', (c) => {
|
||||
return c.render(
|
||||
<h1>
|
||||
<RequestUrl />
|
||||
</h1>,
|
||||
{
|
||||
title: 'Title',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('Should return HTML with layout', async () => {
|
||||
const res = await app.request('http://localhost/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe(
|
||||
'<html><head>Title</head><body><h1>http://localhost/</h1></body></html>'
|
||||
)
|
||||
})
|
||||
|
||||
it('Should return HTML without layout', async () => {
|
||||
const app = new Hono()
|
||||
app.use('*', reactRenderer())
|
||||
app.get('/', (c) =>
|
||||
c.render(
|
||||
<h1>
|
||||
<RequestUrl />
|
||||
</h1>,
|
||||
{ title: 'Title' }
|
||||
)
|
||||
)
|
||||
const res = await app.request('http://localhost/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('<h1>http://localhost/</h1>')
|
||||
})
|
||||
|
||||
it('Should return a default doctype', async () => {
|
||||
const app = new Hono()
|
||||
app.use(
|
||||
'*',
|
||||
reactRenderer(
|
||||
({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
},
|
||||
{ docType: true }
|
||||
)
|
||||
)
|
||||
app.get('/', (c) => c.render(<h1>Hello</h1>, { title: 'Title' }))
|
||||
const res = await app.request('/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('<!DOCTYPE html><html><body><h1>Hello</h1></body></html>')
|
||||
})
|
||||
|
||||
it('Should return a custom doctype', async () => {
|
||||
const app = new Hono()
|
||||
app.use(
|
||||
'*',
|
||||
reactRenderer(
|
||||
({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
},
|
||||
{
|
||||
docType:
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
|
||||
}
|
||||
)
|
||||
)
|
||||
app.get('/', (c) => c.render(<h1>Hello</h1>, { title: 'Title' }))
|
||||
const res = await app.request('/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe(
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"><html><body><h1>Hello</h1></body></html>'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Streaming', () => {
|
||||
it.skip('Vitest does not support Streaming')
|
||||
})
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": "./",
|
||||
"outDir": "./dist",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.tsx",
|
||||
],
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
})
|
55
yarn.lock
55
yarn.lock
|
@ -1833,6 +1833,22 @@ __metadata:
|
|||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@hono/react-renderer@workspace:packages/react-renderer":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@hono/react-renderer@workspace:packages/react-renderer"
|
||||
dependencies:
|
||||
"@types/react": "npm:^18"
|
||||
"@types/react-dom": "npm:^18.2.17"
|
||||
hono: "npm:^3.11.7"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
tsup: "npm:^8.0.1"
|
||||
vitest: "npm:^1.0.4"
|
||||
peerDependencies:
|
||||
hono: "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@hono/sentry@workspace:packages/sentry":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@hono/sentry@workspace:packages/sentry"
|
||||
|
@ -4287,7 +4303,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:^18":
|
||||
"@types/react-dom@npm:^18.2.17":
|
||||
version: 18.2.17
|
||||
resolution: "@types/react-dom@npm:18.2.17"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
checksum: 33b53078ed7e9e0cfc4dc691e938f7db1cc06353bc345947b41b581c3efe2b980c9e4eb6460dbf5ddc521dd91959194c970221a2bd4bfad9d23ebce338e12938
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*, @types/react@npm:^18":
|
||||
version: 18.2.45
|
||||
resolution: "@types/react@npm:18.2.45"
|
||||
dependencies:
|
||||
|
@ -10312,6 +10337,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hono@npm:^3.11.7":
|
||||
version: 3.11.7
|
||||
resolution: "hono@npm:3.11.7"
|
||||
checksum: 6665e26801cb4c4334e09dbb42453bf40e1daae9a44bf9a1ed22815f6cb701c0d9ac1a4452624234f36093996c8afff0a41d2d5b52a77f4f460178be48e022bf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hosted-git-info@npm:^2.1.4":
|
||||
version: 2.8.9
|
||||
resolution: "hosted-git-info@npm:2.8.9"
|
||||
|
@ -16706,6 +16738,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react-dom@npm:18.2.0"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.1.0"
|
||||
scheduler: "npm:^0.23.0"
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^18.0.0":
|
||||
version: 18.2.0
|
||||
resolution: "react-is@npm:18.2.0"
|
||||
|
@ -17441,6 +17485,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scheduler@npm:^0.23.0":
|
||||
version: 0.23.0
|
||||
resolution: "scheduler@npm:0.23.0"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.1.0"
|
||||
checksum: b777f7ca0115e6d93e126ac490dbd82642d14983b3079f58f35519d992fa46260be7d6e6cede433a92db70306310c6f5f06e144f0e40c484199e09c1f7be53dd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scoped-regex@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "scoped-regex@npm:2.1.0"
|
||||
|
|
Loading…
Reference in New Issue