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 correctly
pull/314/head
Yusuke Wada 2023-12-16 10:28:19 +09:00 committed by GitHub
parent ee12e3c08a
commit 9348fa2663
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 544 additions and 1 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/react-renderer': patch
---
feat: introduce React Renderer

View File

@ -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

View File

@ -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"
}
}

View File

@ -0,0 +1,9 @@
export * from './react-renderer'
export type Props = {}
declare module 'hono' {
interface ContextRenderer {
(children: React.ReactElement, props?: Props): Response | Promise<Response>
}
}

View File

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

View File

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

View File

@ -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",
],
}

View File

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})

View File

@ -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"