From 9348fa26635d97ee6f8ceca563488d77afb39889 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 16 Dec 2023 10:28:19 +0900 Subject: [PATCH] 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 --- .changeset/twelve-spiders-fail.md | 5 + packages/react-renderer/README.md | 224 ++++++++++++++++++ packages/react-renderer/package.json | 49 ++++ packages/react-renderer/src/index.ts | 9 + packages/react-renderer/src/react-renderer.ts | 68 ++++++ .../test/react-renderer.test.tsx | 111 +++++++++ packages/react-renderer/tsconfig.json | 16 ++ packages/react-renderer/vitest.config.ts | 8 + yarn.lock | 55 ++++- 9 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 .changeset/twelve-spiders-fail.md create mode 100644 packages/react-renderer/README.md create mode 100644 packages/react-renderer/package.json create mode 100644 packages/react-renderer/src/index.ts create mode 100644 packages/react-renderer/src/react-renderer.ts create mode 100644 packages/react-renderer/test/react-renderer.test.tsx create mode 100644 packages/react-renderer/tsconfig.json create mode 100644 packages/react-renderer/vitest.config.ts diff --git a/.changeset/twelve-spiders-fail.md b/.changeset/twelve-spiders-fail.md new file mode 100644 index 00000000..930d420f --- /dev/null +++ b/.changeset/twelve-spiders-fail.md @@ -0,0 +1,5 @@ +--- +'@hono/react-renderer': patch +--- + +feat: introduce React Renderer diff --git a/packages/react-renderer/README.md b/packages/react-renderer/README.md new file mode 100644 index 00000000..d63b9a8d --- /dev/null +++ b/packages/react-renderer/README.md @@ -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 ( + + +

React + Hono

+
{children}
+ + + ) + }) +) + +app.get('/', (c) => { + return c.render(

Welcome!

) +}) +``` + +### 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 ( + + + {title} + + +
{children}
+ + + ) + }) +) + +app.get('/', (c) => { + return c.render(

Welcome!

, { + title: 'Top Page', + }) +}) +``` + +### `useRequestContext()` + +You can get an instance of `Context` in a function component: + +```tsx +const Component = () => { + const c = useRequestContext() + return

You access {c.req.url}

+} + +app.get('/', (c) => { + return c.render() +}) +``` + +## Options + +### `docType` + +If you set it `true`, `DOCTYPE` will be added: + +```tsx +app.get( + '*', + reactRenderer( + ({ children }) => { + return ( + + +
{children}
+ + + ) + }, + { + docType: true, + } + ) +) +``` + +The HTML is the following: + +```html + + + +

Welcome!

+ + +``` + +You can specify the `docType` as you like. + +```tsx +app.get( + '*', + reactRenderer( + ({ children }) => { + return ( + + +
{children}
+ + + ) + }, + { + docType: + '', + } + ) +) +``` + +### `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 ( + + +
{children}
+ + + ) + }, + { + stream: true, + } + ) +) + +let done = false + +const Component = () => { + if (done) { + return

Done!

+ } + throw new Promise((resolve) => { + done = true + setTimeout(resolve, 1000) + }) +} + +app.get('/', (c) => { + return c.render( + + + + ) +}) +``` + +## Limitation + +A streaming feature is not available on Vite or Vitest. + +## Author + +Yusuke Wada + +## License + +MIT diff --git a/packages/react-renderer/package.json b/packages/react-renderer/package.json new file mode 100644 index 00000000..37817311 --- /dev/null +++ b/packages/react-renderer/package.json @@ -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" + } +} diff --git a/packages/react-renderer/src/index.ts b/packages/react-renderer/src/index.ts new file mode 100644 index 00000000..7e629726 --- /dev/null +++ b/packages/react-renderer/src/index.ts @@ -0,0 +1,9 @@ +export * from './react-renderer' + +export type Props = {} + +declare module 'hono' { + interface ContextRenderer { + (children: React.ReactElement, props?: Props): Response | Promise + } +} diff --git a/packages/react-renderer/src/react-renderer.ts b/packages/react-renderer/src/react-renderer.ts new file mode 100644 index 00000000..c18dd56e --- /dev/null +++ b/packages/react-renderer/src/react-renderer.ts @@ -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 +} + +type BaseProps = { + c: Context + children: React.ReactElement +} + +const RequestContext = React.createContext(null) + +const createRenderer = + (c: Context, component?: React.FC, 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 + ? '' + : '' + const body = + docType + renderToString(React.createElement(RequestContext.Provider, { value: c }, node)) + return c.html(body) + } + } + +export const reactRenderer = ( + component?: React.FC, + 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 = (): Context => { + const c = React.useContext(RequestContext) + if (!c) { + throw new Error('RequestContext is not provided.') + } + return c +} diff --git a/packages/react-renderer/test/react-renderer.test.tsx b/packages/react-renderer/test/react-renderer.test.tsx new file mode 100644 index 00000000..19872760 --- /dev/null +++ b/packages/react-renderer/test/react-renderer.test.tsx @@ -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 ( + + {title} + {children} + + ) + }) + ) + app.get('/', (c) => { + return c.render( +

+ +

, + { + 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( + 'Title

http://localhost/

' + ) + }) + + it('Should return HTML without layout', async () => { + const app = new Hono() + app.use('*', reactRenderer()) + app.get('/', (c) => + c.render( +

+ +

, + { title: 'Title' } + ) + ) + const res = await app.request('http://localhost/') + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe('

http://localhost/

') + }) + + it('Should return a default doctype', async () => { + const app = new Hono() + app.use( + '*', + reactRenderer( + ({ children }) => { + return ( + + {children} + + ) + }, + { docType: true } + ) + ) + app.get('/', (c) => c.render(

Hello

, { title: 'Title' })) + const res = await app.request('/') + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe('

Hello

') + }) + + it('Should return a custom doctype', async () => { + const app = new Hono() + app.use( + '*', + reactRenderer( + ({ children }) => { + return ( + + {children} + + ) + }, + { + docType: + '', + } + ) + ) + app.get('/', (c) => c.render(

Hello

, { title: 'Title' })) + const res = await app.request('/') + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toBe( + '

Hello

' + ) + }) +}) + +describe('Streaming', () => { + it.skip('Vitest does not support Streaming') +}) diff --git a/packages/react-renderer/tsconfig.json b/packages/react-renderer/tsconfig.json new file mode 100644 index 00000000..624fd152 --- /dev/null +++ b/packages/react-renderer/tsconfig.json @@ -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", + ], +} \ No newline at end of file diff --git a/packages/react-renderer/vitest.config.ts b/packages/react-renderer/vitest.config.ts new file mode 100644 index 00000000..17b54e48 --- /dev/null +++ b/packages/react-renderer/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index fa1bb857..9232131f 100644 --- a/yarn.lock +++ b/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"