├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.yml │ ├── 2-feature-request.yml │ └── config.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lock ├── eslint.config.mjs ├── mocks ├── api │ └── routes │ │ ├── about │ │ └── [name].ts │ │ ├── index.ts │ │ └── middleware │ │ └── index.ts ├── app-alias-tsconfig-paths │ ├── SomeComponent.tsx │ ├── islands │ │ └── Counter.tsx │ └── routes │ │ ├── _renderer.tsx │ │ ├── has-islands.tsx │ │ └── has-no-islands.tsx ├── app-alias │ ├── islands │ │ └── Counter.tsx │ └── routes │ │ ├── _renderer.tsx │ │ ├── has-islands.tsx │ │ └── has-no-islands.tsx ├── app-function │ └── routes │ │ ├── api-response.tsx │ │ ├── async-jsx.tsx │ │ ├── async-response.tsx │ │ └── jsx-response.tsx ├── app-islands-in-preserved │ ├── islands │ │ └── Counter.tsx │ └── routes │ │ ├── _404.tsx │ │ ├── _error.tsx │ │ ├── _renderer.tsx │ │ ├── index.tsx │ │ ├── nested │ │ ├── _renderer.tsx │ │ └── post.mdx │ │ └── throw_error.tsx ├── app-link │ └── routes │ │ ├── _renderer.tsx │ │ ├── classic │ │ └── index.tsx │ │ └── index.tsx ├── app-nested-dynamic-routes │ └── routes │ │ └── resource │ │ ├── [resourceId1] │ │ ├── index.tsx │ │ └── resource2 │ │ │ ├── [resourceId2] │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── new.tsx │ │ ├── index.tsx │ │ └── new.tsx ├── app-nested-middleware │ ├── middleware │ │ └── appendHeader.ts │ └── routes │ │ ├── _middleware.ts │ │ ├── index.tsx │ │ └── nested │ │ ├── _middleware.ts │ │ ├── foo │ │ ├── _middleware.ts │ │ ├── bar │ │ │ ├── [id] │ │ │ │ └── index.tsx │ │ │ ├── _middleware.ts │ │ │ ├── baz │ │ │ │ ├── _middleware.ts │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ │ └── index.tsx ├── app-nested │ └── routes │ │ ├── nested │ │ ├── _renderer.tsx │ │ ├── foo │ │ │ ├── _renderer.tsx │ │ │ ├── bar │ │ │ │ ├── _renderer.tsx │ │ │ │ ├── baz │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ │ └── top.tsx ├── app-route-groups │ ├── routes │ │ ├── _renderer.tsx │ │ ├── blog │ │ │ ├── (content-group) │ │ │ │ ├── _middleware.ts │ │ │ │ ├── _renderer.tsx │ │ │ │ └── hello-world.mdx │ │ │ └── index.tsx │ │ └── index.tsx │ └── server.ts ├── app-script │ ├── islands │ │ └── Component.tsx │ └── routes │ │ ├── _async_renderer.tsx │ │ ├── _nonce_renderer.tsx │ │ ├── _renderer.tsx │ │ ├── classic │ │ └── index.tsx │ │ └── index.tsx ├── app │ ├── client.ts │ ├── components │ │ ├── $counter.tsx │ │ ├── Badge.tsx │ │ ├── CounterCard.tsx │ │ └── islands │ │ │ └── not-island.tsx │ ├── global.d.ts │ ├── globals.css │ ├── islands │ │ ├── Badge.tsx │ │ ├── Counter.tsx │ │ └── NamedCounter.tsx │ ├── routes │ │ ├── _404.tsx │ │ ├── _error.tsx │ │ ├── _renderer.tsx │ │ ├── about │ │ │ ├── [name].tsx │ │ │ └── [name] │ │ │ │ ├── _middleware.ts │ │ │ │ ├── _renderer.tsx │ │ │ │ ├── address.tsx │ │ │ │ └── hobbies │ │ │ │ └── [hobby_name] │ │ │ │ ├── _middleware.ts │ │ │ │ └── index.tsx │ │ ├── api.tsx │ │ ├── app │ │ │ └── nested │ │ │ │ └── index.tsx │ │ ├── directory │ │ │ ├── $counter.tsx │ │ │ ├── _Counter.island.tsx │ │ │ ├── _error.tsx │ │ │ ├── index.tsx │ │ │ ├── sub │ │ │ │ └── throw_error.tsx │ │ │ └── throw_error.tsx │ │ ├── fc.tsx │ │ ├── index.tsx │ │ ├── interaction │ │ │ ├── anywhere.tsx │ │ │ ├── children.tsx │ │ │ ├── error-boundary.tsx │ │ │ ├── index.tsx │ │ │ ├── nested.tsx │ │ │ ├── suspense-islands.tsx │ │ │ ├── suspense-never.tsx │ │ │ └── suspense.tsx │ │ ├── non-interactive.tsx │ │ ├── not-found.tsx │ │ ├── post.mdx │ │ └── throw_error.tsx │ └── server.ts ├── tsconfig.json └── vite.config.ts ├── package.json ├── src ├── client │ ├── client.ts │ ├── index.ts │ ├── runtime.test.ts │ └── runtime.ts ├── constants.ts ├── factory │ ├── factory.ts │ └── index.ts ├── index.ts ├── server │ ├── base.ts │ ├── components │ │ ├── has-islands.tsx │ │ ├── index.ts │ │ ├── link.tsx │ │ └── script.tsx │ ├── context-storage.ts │ ├── index.ts │ ├── server.ts │ ├── utils │ │ ├── file.test.ts │ │ ├── file.ts │ │ ├── path.test.ts │ │ └── path.ts │ └── with-defaults.ts ├── types.ts └── vite │ ├── client.ts │ ├── components │ ├── honox-island.test.tsx │ ├── honox-island.tsx │ └── index.ts │ ├── index.ts │ ├── inject-importing-islands.ts │ ├── island-components.test.ts │ ├── island-components.ts │ ├── restart-on-add-unlink.ts │ └── utils │ ├── path.test.ts │ └── path.ts ├── test-e2e ├── e2e.test.ts └── playwright.config.ts ├── test-integration ├── api.test.ts ├── apps.test.ts └── vitest.config.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report an issue that should be fixed 3 | labels: [triage] 4 | body: 5 | - type: input 6 | attributes: 7 | label: What version of HonoX are you using? 8 | placeholder: 0.0.0 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: What steps can reproduce the bug? 14 | description: Explain the bug and provide a code snippet that can reproduce it. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: What is the expected behavior? 20 | - type: textarea 21 | attributes: 22 | label: What do you see instead? 23 | - type: textarea 24 | attributes: 25 | label: Additional information 26 | description: Is there anything else you think we should know? 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea, feature, or enhancement 3 | labels: [enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting an idea. It helps make Hono better. 9 | 10 | - type: textarea 11 | attributes: 12 | label: What is the feature you are proposing? 13 | description: A clear description of what you want to happen. 14 | validations: 15 | required: true 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main, next] 5 | pull_request: 6 | branches: ['*'] 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v1 14 | with: 15 | bun-version: 1.1.3 16 | - run: bun install 17 | - run: bunx playwright install chromium 18 | - run: bun run format 19 | - run: bun run lint 20 | - run: bun run build 21 | - run: bun run test 22 | 23 | ci-windows: 24 | runs-on: windows-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: oven-sh/setup-bun@v1 28 | with: 29 | bun-version: 1.1.27 30 | - run: bun install 31 | - run: bunx playwright install chromium 32 | - run: bun run test 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | sandbox 3 | .hono 4 | test-results 5 | 6 | # Cloudflare 7 | worker 8 | .wrangler 9 | .mf 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | .pnpm-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # Snowpack dependency directory (https://snowpack.dev/) 56 | web_modules/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional stylelint cache 68 | .stylelintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variable files 86 | .env 87 | .env.development.local 88 | .env.test.local 89 | .env.production.local 90 | .env.local 91 | 92 | # parcel-bundler cache (https://parceljs.org/) 93 | .cache 94 | .parcel-cache 95 | 96 | # Next.js build output 97 | .next 98 | out 99 | 100 | # Nuxt.js build / generate output 101 | .nuxt 102 | 103 | # Gatsby files 104 | .cache/ 105 | # Comment in the public line in if your project uses Gatsby and not Next.js 106 | # https://nextjs.org/blog/next-9-1#public-directory-support 107 | # public 108 | 109 | # vuepress build output 110 | .vuepress/dist 111 | 112 | # vuepress v2.x temp and cache directory 113 | .temp 114 | .cache 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # IDE-specific settings 132 | .idea 133 | 134 | # yarn 135 | .yarn/* 136 | !.yarn/patches 137 | !.yarn/plugins 138 | !.yarn/releases 139 | !.yarn/sdks 140 | !.yarn/versions 141 | 142 | # Swap the comments on the following lines if you wish to use zero-installs 143 | # In that case, don't forget to run `yarn config set enableGlobalCache false`! 144 | # Documentation here: https://yarnpkg.com/features/caching#zero-installs 145 | 146 | #!.yarn/cache 147 | .pnp.* 148 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact" 7 | ], 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit" 10 | } 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 - present, Yusuke Wada and Hono contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HonoX 2 | 3 | **HonoX** is a simple and fast meta-framework for creating full-stack websites or Web APIs - (formerly _[Sonik](https://github.com/sonikjs/sonik)_). It stands on the shoulders of giants; built on [Hono](https://hono.dev/), [Vite](https://vitejs.dev/), and UI libraries. 4 | 5 | **Note**: _HonoX is currently in the "alpha stage". Breaking changes are introduced without following semantic versioning._ 6 | 7 | ## Features 8 | 9 | - **File-based routing** - You can create a large application like Next.js. 10 | - **Fast SSR** - Rendering is ultra-fast thanks to Hono. 11 | - **BYOR** - You can bring your own renderer, not only one using hono/jsx. 12 | - **Islands hydration** - If you want interactions, create an island. JavaScript is hydrated only for it. 13 | - **Middleware** - It works as Hono, so you can use a lot of Hono's middleware. 14 | 15 | ## Installing 16 | 17 | You can install the `honox` package from the npm. 18 | 19 | ```txt 20 | npm install hono honox 21 | ``` 22 | 23 | ## Starter template 24 | 25 | If you are starting a new HonoX project, use the `hono-create` command. Run the following and choose `x-basic` (use the arrow keys to find the option). 26 | 27 | ```txt 28 | npm create hono@latest 29 | ``` 30 | 31 | ## Get Started - Basic 32 | 33 | Let's create a basic HonoX application using hono/jsx as a renderer. This application has no client JavaScript and renders JSX on the server side. 34 | 35 | ### Project Structure 36 | 37 | Below is a typical project structure for a HonoX application. 38 | 39 | ```txt 40 | . 41 | ├── app 42 | │   ├── global.d.ts // global type definitions 43 | │   ├── routes 44 | │   │   ├── _404.tsx // not found page 45 | │   │   ├── _error.tsx // error page 46 | │   │   ├── _renderer.tsx // renderer definition 47 | │   │   ├── merch 48 | │   │   │   └── [...slug].tsx // matches `/merch/:category`, `/merch/:category/:item`, `/merch/:category/:item/:variant` 49 | │   │   ├── about 50 | │   │   │   └── [name].tsx // matches `/about/:name` 51 | │   │   ├── blog 52 | │   │   │   ├── index.tsx // matches /blog 53 | │   │   │   └── (content) 54 | │   │   │      ├── _renderer.tsx // renderer definition for routes inside this directory 55 | │   │   │      └── [name].tsx // matches `/blog/:name` 56 | │   │   └── index.tsx // matches `/` 57 | │   └── server.ts // server entry file 58 | ├── package.json 59 | ├── tsconfig.json 60 | └── vite.config.ts 61 | ``` 62 | 63 | ### `vite.config.ts` 64 | 65 | The minimum Vite setup for development is as follows: 66 | 67 | ```ts 68 | import { defineConfig } from 'vite' 69 | import honox from 'honox/vite' 70 | 71 | export default defineConfig({ 72 | plugins: [honox()], 73 | }) 74 | ``` 75 | 76 | ### Server Entry File 77 | 78 | A server entry file is required. The file should be placed at `app/server.ts`. This file is first called by the Vite during the development or build phase. 79 | 80 | In the entry file, simply initialize your app using the `createApp()` function. `app` will be an instance of Hono, so you can use Hono's middleware and the `showRoutes()` in `hono/dev`. 81 | 82 | ```ts 83 | // app/server.ts 84 | import { createApp } from 'honox/server' 85 | import { showRoutes } from 'hono/dev' 86 | 87 | const app = createApp() 88 | 89 | showRoutes(app) 90 | 91 | export default app 92 | ``` 93 | 94 | ### Routes 95 | 96 | There are three ways to define routes. 97 | 98 | #### 1. `createRoute()` 99 | 100 | Each route should return an array of `Handler | MiddlewareHandler`. `createRoute()` is a helper function to return it. You can write a route for a GET request with `default export`. 101 | 102 | ```tsx 103 | // app/routes/index.tsx 104 | // `createRoute()` helps you create handlers 105 | import { createRoute } from 'honox/factory' 106 | 107 | export default createRoute((c) => { 108 | return c.render( 109 |
110 |

Hello!

111 |
112 | ) 113 | }) 114 | ``` 115 | 116 | You can also handle methods other than GET by `export` `POST`, `PUT`, and `DELETE`. 117 | 118 | ```tsx 119 | // app/routes/index.tsx 120 | import { createRoute } from 'honox/factory' 121 | import { getCookie, setCookie } from 'hono/cookie' 122 | 123 | export const POST = createRoute(async (c) => { 124 | const { name } = await c.req.parseBody<{ name: string }>() 125 | setCookie(c, 'name', name) 126 | return c.redirect('/') 127 | }) 128 | 129 | export default createRoute((c) => { 130 | const name = getCookie(c, 'name') ?? 'no name' 131 | return c.render( 132 |
133 |

Hello, {name}!

134 |
135 | 136 | 137 |
138 |
139 | ) 140 | }) 141 | ``` 142 | 143 | #### 2. Using a Hono instance 144 | 145 | You can create API endpoints by exporting an instance of the Hono object. 146 | 147 | ```ts 148 | // app/routes/about/index.ts 149 | import { Hono } from 'hono' 150 | 151 | const app = new Hono() 152 | 153 | // matches `/about/:name` 154 | app.get('/:name', (c) => { 155 | const name = c.req.param('name') 156 | return c.json({ 157 | 'your name is': name, 158 | }) 159 | }) 160 | 161 | export default app 162 | ``` 163 | 164 | #### 3. Just return JSX 165 | 166 | Or simply, you can just return JSX. 167 | 168 | ```tsx 169 | // app/routes/index.tsx 170 | export default function Home(_c: Context) { 171 | return

Welcome!

172 | } 173 | ``` 174 | 175 | ### Renderer 176 | 177 | Define your renderer - the middleware that does `c.setRender()` - by writing it in `_renderer.tsx`. 178 | 179 | Before writing `_renderer.tsx`, write the Renderer type definition in `global.d.ts`. 180 | 181 | ```ts 182 | // app/global.d.ts 183 | import type {} from 'hono' 184 | 185 | type Head = { 186 | title?: string 187 | } 188 | 189 | declare module 'hono' { 190 | interface ContextRenderer { 191 | (content: string | Promise, head?: Head): Response | Promise 192 | } 193 | } 194 | ``` 195 | 196 | The JSX Renderer middleware allows you to create a Renderer as follows: 197 | 198 | ```tsx 199 | // app/routes/_renderer.tsx 200 | import { jsxRenderer } from 'hono/jsx-renderer' 201 | 202 | export default jsxRenderer(({ children, title }) => { 203 | return ( 204 | 205 | 206 | 207 | 208 | {title ? {title} : <>} 209 | 210 | {children} 211 | 212 | ) 213 | }) 214 | ``` 215 | 216 | The `_renderer.tsx` is applied under each directory, and the `app/routes/posts/_renderer.tsx` is applied in `app/routes/posts/*`. 217 | 218 | ### Not Found page 219 | 220 | You can write a custom Not Found page in `_404.tsx`. 221 | 222 | ```tsx 223 | // app/routes/_404.tsx 224 | import { NotFoundHandler } from 'hono' 225 | 226 | const handler: NotFoundHandler = (c) => { 227 | return c.render(

Sorry, Not Found...

) 228 | } 229 | 230 | export default handler 231 | ``` 232 | 233 | ### Error Page 234 | 235 | You can write a custom Error page in `_error.tsx`. 236 | 237 | ```tsx 238 | // app/routes/_error.tsx 239 | import { ErrorHandler } from 'hono' 240 | 241 | const handler: ErrorHandler = (e, c) => { 242 | return c.render(

Error! {e.message}

) 243 | } 244 | 245 | export default handler 246 | ``` 247 | 248 | ## Get Started - with Client 249 | 250 | Let's create an application that includes a client side. Here, we will use hono/jsx/dom. 251 | 252 | ### Project Structure 253 | 254 | Below is the project structure of a minimal application including a client side: 255 | 256 | ```txt 257 | . 258 | ├── app 259 | │   ├── client.ts // client entry file 260 | │   ├── global.d.ts 261 | │   ├── islands 262 | │   │   └── counter.tsx // island component 263 | │   ├── routes 264 | │   │   ├── _renderer.tsx 265 | │   │   └── index.tsx 266 | │   └── server.ts 267 | ├── package.json 268 | ├── tsconfig.json 269 | └── vite.config.ts 270 | ``` 271 | 272 | ### Renderer 273 | 274 | This is a `_renderer.tsx`, which will load the `/app/client.ts` entry file for the client. It will load the JavaScript file for production according to the variable `import.meta.env.PROD`. And renders the inside of `` if there are islands on that page. 275 | 276 | ```tsx 277 | // app/routes/_renderer.tsx 278 | import { jsxRenderer } from 'hono/jsx-renderer' 279 | import { HasIslands } from 'honox/server' 280 | 281 | export default jsxRenderer(({ children }) => { 282 | return ( 283 | 284 | 285 | 286 | 287 | {import.meta.env.PROD ? ( 288 | 289 | 290 | 291 | ) : ( 292 | 293 | )} 294 | 295 | {children} 296 | 297 | ) 298 | }) 299 | ``` 300 | 301 | If you have a manifest file in `dist/.vite/manifest.json`, you can easily write it using ` 469 | ) : ( 470 | 471 | )} 472 | {title ? {title} : ''} 473 | 474 | {children} 475 | 476 | ) 477 | }) 478 | ``` 479 | 480 | The `app/client.ts` will be like this. 481 | 482 | ```ts 483 | // app/client.ts 484 | import { createClient } from 'honox/client' 485 | 486 | createClient({ 487 | hydrate: async (elem, root) => { 488 | const { hydrateRoot } = await import('react-dom/client') 489 | hydrateRoot(root, elem) 490 | }, 491 | createElement: async (type: any, props: any) => { 492 | const { createElement } = await import('react') 493 | return createElement(type, props) 494 | }, 495 | }) 496 | ``` 497 | 498 | Configure react in `vite.config.ts`. 499 | 500 | ```ts 501 | // vite.config.ts 502 | import build from '@hono/vite-build/cloudflare-pages' 503 | import honox from 'honox/vite' 504 | import { defineConfig } from 'vite' 505 | 506 | export default defineConfig(({ mode }) => { 507 | if (mode === 'client') { 508 | return { 509 | build: { 510 | rollupOptions: { 511 | input: ['./app/client.ts'], 512 | output: { 513 | entryFileNames: 'static/client.js', 514 | chunkFileNames: 'static/assets/[name]-[hash].js', 515 | assetFileNames: 'static/assets/[name].[ext]', 516 | }, 517 | }, 518 | emptyOutDir: false, 519 | }, 520 | } 521 | } else { 522 | return { 523 | ssr: { 524 | external: ['react', 'react-dom'], 525 | }, 526 | plugins: [honox(), build()], 527 | } 528 | } 529 | }) 530 | ``` 531 | 532 | Adjust `tsconfig.json` jsx factory function option. 533 | 534 | ```ts 535 | // tsconfig.json 536 | { 537 | "compilerOptions": { 538 | ... 539 | "jsxImportSource": "react" 540 | ... 541 | } 542 | } 543 | 544 | ``` 545 | 546 | #### Use React with ` 12 | 13 | 14 | 15 | ) 16 | }, 17 | { 18 | docType: false, 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /mocks/app-alias-tsconfig-paths/routes/has-islands.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '@mocks/SomeComponent' 2 | 3 | export default function HasIslands() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app-alias-tsconfig-paths/routes/has-no-islands.tsx: -------------------------------------------------------------------------------- 1 | export default function HasNoIslands() { 2 | return

No Islands

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-alias/islands/Counter.tsx: -------------------------------------------------------------------------------- 1 | export default function Counter() { 2 | return
Counter
3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-alias/routes/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import { HasIslands } from '../../../src/server' 3 | 4 | export default jsxRenderer( 5 | ({ children }) => { 6 | return ( 7 | 8 | 9 | {children} 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | }, 17 | { 18 | docType: false, 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /mocks/app-alias/routes/has-islands.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '@/islands/Counter' 2 | 3 | export default function HasIslands() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app-alias/routes/has-no-islands.tsx: -------------------------------------------------------------------------------- 1 | export default function HasNoIslands() { 2 | return

No Islands

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-function/routes/api-response.tsx: -------------------------------------------------------------------------------- 1 | export default function ApiResponse() { 2 | return new Response(JSON.stringify({ message: 'API Response' }), { 3 | headers: { 'Content-Type': 'application/json' }, 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app-function/routes/async-jsx.tsx: -------------------------------------------------------------------------------- 1 | export default async function AsyncJsx() { 2 | await new Promise((resolve) => setTimeout(resolve, 10)) // simulate async work 3 | return
Async JSX Response
4 | } 5 | -------------------------------------------------------------------------------- /mocks/app-function/routes/async-response.tsx: -------------------------------------------------------------------------------- 1 | export default async function AsyncResponse() { 2 | return new Response(JSON.stringify({ message: 'Async Response' }), { 3 | status: 201, 4 | headers: { 5 | 'Content-Type': 'application/json', 6 | 'x-custom': 'async', 7 | }, 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /mocks/app-function/routes/jsx-response.tsx: -------------------------------------------------------------------------------- 1 | export default function JsxResponse() { 2 | return
JSX Response
3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/islands/Counter.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'hono/jsx' 2 | import { useState } from 'hono/jsx' 3 | 4 | export default function Counter({ 5 | children, 6 | initial = 0, 7 | id = '', 8 | }: PropsWithChildren<{ 9 | initial?: number 10 | id?: string 11 | }>) { 12 | const [count, setCount] = useState(initial) 13 | const increment = () => setCount(count + 1) 14 | return ( 15 |
16 |

Count: {count}

17 | 18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/_404.tsx: -------------------------------------------------------------------------------- 1 | import type { NotFoundHandler } from 'hono' 2 | import Counter from '../islands/Counter' 3 | 4 | const handler: NotFoundHandler = (c) => { 5 | return c.render(, { 6 | title: 'Not Found', 7 | }) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/_error.tsx: -------------------------------------------------------------------------------- 1 | import type { ErrorHandler } from 'hono' 2 | import Counter from '../islands/Counter' 3 | 4 | const handler: ErrorHandler = (e, c) => { 5 | return c.render(, { 6 | title: 'Internal Server Error', 7 | }) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import { HasIslands } from '../../../src/server' 3 | 4 | export default jsxRenderer( 5 | ({ children, title }) => { 6 | return ( 7 | 8 | 9 | {title} 10 | 11 | 12 | {children} 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | }, 20 | { 21 | docType: false, 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(

Hello

, { 5 | title: 'This is a title', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/nested/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import Counter from '../../islands/Counter' 3 | 4 | export default jsxRenderer( 5 | ({ Layout, children }) => { 6 | return ( 7 | 8 | 9 | <>{children} 10 | 11 | ) 12 | }, 13 | { 14 | docType: false, 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/nested/post.mdx: -------------------------------------------------------------------------------- 1 | # Hello MDX 2 | -------------------------------------------------------------------------------- /mocks/app-islands-in-preserved/routes/throw_error.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute(() => { 4 | throw new Error('foo') 5 | }) 6 | -------------------------------------------------------------------------------- /mocks/app-link/routes/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import { Link } from '../../../src/server' 3 | 4 | export default jsxRenderer( 5 | ({ children }) => { 6 | return ( 7 | 8 | 9 | 15 | 16 | {children} 17 | 18 | ) 19 | }, 20 | { 21 | docType: false, 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /mocks/app-link/routes/classic/index.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | const app = new Hono() 4 | 5 | app.get('/', (c) => { 6 | return c.render( 7 |
8 |
9 |
10 | ) 11 | }) 12 | 13 | export default app 14 | -------------------------------------------------------------------------------- /mocks/app-link/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Hello() { 2 | return ( 3 |
4 |
5 |
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/[resourceId1]/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | const { resourceId1 } = c.req.param() 5 | return c.render(Resource Id {resourceId1}, { 6 | title: `Resource Id ${resourceId1}`, 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/[resourceId1]/resource2/[resourceId2]/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | const { resourceId1, resourceId2 } = c.req.param() 5 | return c.render( 6 | 7 | Resource2 Id {resourceId1} / {resourceId2} 8 | , 9 | { 10 | title: `Resource2 Id ${resourceId1}/${resourceId2}`, 11 | } 12 | ) 13 | }) 14 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/[resourceId1]/resource2/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(Resource2 Home, { 5 | title: 'Resource 2 Home', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/[resourceId1]/resource2/new.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(
Create new resource 2
, { 5 | title: 'Create', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(
Resource Home
, { 5 | title: 'Resource Home', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-nested-dynamic-routes/routes/resource/new.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(
Create new resource
, { 5 | title: 'Create', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/middleware/appendHeader.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareHandler } from 'hono' 2 | 3 | export const headerMiddleware: (headerName: string) => MiddlewareHandler = 4 | (headerName: string) => (ctx, next) => { 5 | ctx.res.headers.append(headerName, headerName) 6 | return next() 7 | } 8 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | import { headerMiddleware } from '../middleware/appendHeader' 3 | 4 | export default createRoute(headerMiddleware('top')) 5 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Top() { 2 | return

Top

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../src/factory' 2 | import { headerMiddleware } from '../../middleware/appendHeader' 3 | 4 | export default createRoute(headerMiddleware('sub')) 5 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../src/factory' 2 | import { headerMiddleware } from '../../../middleware/appendHeader' 3 | 4 | export default createRoute(headerMiddleware('foo')) 5 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/bar/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Id() { 2 | return

Id

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/bar/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../src/factory' 2 | import { headerMiddleware } from '../../../../middleware/appendHeader' 3 | 4 | export default createRoute(headerMiddleware('bar')) 5 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/bar/baz/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../../src/factory' 2 | import { headerMiddleware } from '../../../../../middleware/appendHeader' 3 | 4 | export default createRoute(headerMiddleware('baz')) 5 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/bar/baz/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Baz() { 2 | return

Baz

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/bar/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Bar() { 2 | return

Bar

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/foo/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Foo() { 2 | return

Foo

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested-middleware/routes/nested/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Nested() { 2 | return

Nested

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | 3 | export default jsxRenderer( 4 | ({ children }) => { 5 | return ( 6 |
7 | <>{children} 8 |
9 | ) 10 | }, 11 | { 12 | docType: false, 13 | } 14 | ) 15 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/foo/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | 3 | export default jsxRenderer( 4 | ({ children, Layout }) => { 5 | return ( 6 | 7 | <> 8 | 9 | {children} 10 | 11 | 12 | ) 13 | }, 14 | { 15 | docType: false, 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/foo/bar/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | 3 | export default jsxRenderer( 4 | ({ children, Layout }) => { 5 | return ( 6 | 7 | <> 8 | 9 | {children} 10 | 11 | 12 | ) 13 | }, 14 | { 15 | docType: false, 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/foo/bar/baz/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Baz() { 2 | return

Baz

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/foo/bar/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Bar() { 2 | return

Bar

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/foo/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Foo() { 2 | return

Foo

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/nested/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Nested() { 2 | return

Nested

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-nested/routes/top.tsx: -------------------------------------------------------------------------------- 1 | export default function Top() { 2 | return

Top

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import { HasIslands } from '../../../src/server' 3 | 4 | export default jsxRenderer( 5 | ({ children, title }) => { 6 | return ( 7 | 8 | 9 | {title} 10 | 11 | 12 | {children} 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | }, 20 | { stream: true } 21 | ) 22 | -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/blog/(content-group)/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from 'hono/factory' 2 | import { createRoute } from '../../../../../src/factory' 3 | 4 | const addHeader = createMiddleware(async (c, next) => { 5 | await next() 6 | c.res.headers.append('x-message', 'from middleware for (content-group)') 7 | }) 8 | 9 | export default createRoute(addHeader) 10 | -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/blog/(content-group)/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | 3 | export default jsxRenderer( 4 | ({ children, Layout }) => { 5 | return ( 6 | 7 |
8 |

Blog

9 | {children} 10 |
11 |
12 | ) 13 | }, 14 | { stream: true } 15 | ) 16 | -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/blog/(content-group)/hello-world.mdx: -------------------------------------------------------------------------------- 1 | Hello World -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/blog/index.tsx: -------------------------------------------------------------------------------- 1 | export default function BLogPosts() { 2 | return ( 3 |
Here lies the blog posts
4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app-route-groups/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(

Hello

, { 5 | title: 'This is a title', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app-route-groups/server.ts: -------------------------------------------------------------------------------- 1 | import { showRoutes } from 'hono/dev' 2 | import { logger } from 'hono/logger' 3 | import { createApp } from '../../src/server' 4 | 5 | const ROUTES = import.meta.glob('./routes/**/[a-z[-][a-z[_-]*.(tsx|ts)', { 6 | eager: true, 7 | }) 8 | 9 | const RENDERER = import.meta.glob('./routes/**/_renderer.tsx', { 10 | eager: true, 11 | }) 12 | 13 | const NOT_FOUND = import.meta.glob('./routes/**/_404.(ts|tsx)', { 14 | eager: true, 15 | }) 16 | 17 | const ERROR = import.meta.glob('./routes/**/_error.(ts|tsx)', { 18 | eager: true, 19 | }) 20 | 21 | const app = createApp({ 22 | // @ts-expect-error type is not specified 23 | ROUTES, 24 | // @ts-expect-error type is not specified 25 | RENDERER, 26 | // @ts-expect-error type is not specified 27 | NOT_FOUND, 28 | // @ts-expect-error type is not specified 29 | ERROR, 30 | root: './routes', 31 | init: (app) => { 32 | app.use(logger()) 33 | }, 34 | }) 35 | showRoutes(app, { 36 | verbose: true, 37 | }) 38 | 39 | export default app 40 | -------------------------------------------------------------------------------- /mocks/app-script/islands/Component.tsx: -------------------------------------------------------------------------------- 1 | export default function Component() { 2 | return

Component

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app-script/routes/_async_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | import { Script } from '../../../src/server' 3 | 4 | export default jsxRenderer( 5 | ({ children }) => { 6 | return ( 7 | 8 | 9 | {children} 10 | 15 | 16 | 17 | 18 | ) 19 | }, 20 | { stream: true } 21 | ) 22 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name].tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../src/factory' 2 | import Badge from '../../components/Badge' 3 | 4 | export const POST = createRoute((c) => { 5 | return c.text('Created!', 201) 6 | }) 7 | 8 | export default createRoute((c) => { 9 | const { name } = c.req.param() 10 | return c.render( 11 | <> 12 |

It's {name}

13 | 14 | , 15 | { 16 | title: name, 17 | } 18 | ) 19 | }) 20 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name]/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from 'hono/factory' 2 | import { createRoute } from '../../../../../src/factory' 3 | 4 | const addHeader = createMiddleware(async (c, next) => { 5 | await next() 6 | c.res.headers.append('x-message', 'from middleware') 7 | }) 8 | 9 | export default createRoute(addHeader) 10 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name]/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import { jsxRenderer } from 'hono/jsx-renderer' 2 | 3 | export default jsxRenderer(({ children, title }) => { 4 | return ( 5 | 6 | 7 | {title} 8 | 9 | 10 |

About

11 |
{children}
12 | 13 | 14 | ) 15 | }) 16 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name]/address.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | const { name } = c.req.param() 5 | return c.render({name}'s address, { 6 | title: `${name}'s address`, 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name]/hobbies/[hobby_name]/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from 'hono/factory' 2 | import { createRoute } from '../../../../../../../src/factory' 3 | 4 | const addHeader = createMiddleware(async (c, next) => { 5 | await next() 6 | c.res.headers.append('x-message-nested', 'from nested middleware') 7 | }) 8 | 9 | export default createRoute(addHeader) 10 | -------------------------------------------------------------------------------- /mocks/app/routes/about/[name]/hobbies/[hobby_name]/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | const { name, hobby_name } = c.req.param() 5 | return c.render( 6 |

7 | {name}'s hobby is {hobby_name} 8 |

9 | ) 10 | }) 11 | -------------------------------------------------------------------------------- /mocks/app/routes/api.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export const POST = createRoute((c) => { 4 | return c.json( 5 | { 6 | message: 'created', 7 | ok: true, 8 | }, 9 | 201 10 | ) 11 | }) 12 | 13 | export default createRoute((c) => { 14 | c.header('X-Custom', 'Hello') 15 | return c.json({ 16 | foo: 'bar', 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /mocks/app/routes/app/nested/index.tsx: -------------------------------------------------------------------------------- 1 | export default function () { 2 | return

Nested

3 | } 4 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/$counter.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'hono/jsx' 2 | import { useState } from 'hono/jsx' 3 | 4 | export default function Counter({ 5 | children, 6 | initial = 0, 7 | id = '', 8 | }: PropsWithChildren<{ 9 | initial?: number 10 | id?: string 11 | }>) { 12 | const [count, setCount] = useState(initial) 13 | const increment = () => setCount(count + 1) 14 | return ( 15 |
16 |

DollarCount: {count}

17 | 18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/_Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'hono/jsx' 2 | import { useState } from 'hono/jsx' 3 | 4 | export default function Counter({ 5 | children, 6 | initial = 0, 7 | id = '', 8 | }: PropsWithChildren<{ 9 | initial?: number 10 | id?: string 11 | }>) { 12 | const [count, setCount] = useState(initial) 13 | const increment = () => setCount(count + 1) 14 | return ( 15 |
16 |

UnderScoreCount: {count}

17 | 18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/_error.tsx: -------------------------------------------------------------------------------- 1 | import type { ErrorHandler } from 'hono' 2 | 3 | const handler: ErrorHandler = (error, c) => { 4 | return c.render(

Custom Error in /directory: {error.message}

) 5 | } 6 | 7 | export default handler 8 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/index.tsx: -------------------------------------------------------------------------------- 1 | import DollarCounter from './$counter' 2 | import UnderScoreCounter from './_Counter.island' 3 | 4 | export default function Interaction() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/sub/throw_error.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../../src/factory' 2 | 3 | export default createRoute(() => { 4 | throw new Error('Foo') 5 | }) 6 | -------------------------------------------------------------------------------- /mocks/app/routes/directory/throw_error.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../../src/factory' 2 | 3 | export default createRoute(() => { 4 | throw new Error('Foo') 5 | }) 6 | -------------------------------------------------------------------------------- /mocks/app/routes/fc.tsx: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono' 2 | 3 | // Export a function 4 | export default function FC(c: Context) { 5 | return

Function from {c.req.path}

6 | } 7 | -------------------------------------------------------------------------------- /mocks/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.render(

Hello

, { 5 | title: 'This is a title', 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/anywhere.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '../../components/$counter' 2 | 3 | export default function Interaction() { 4 | return ( 5 | <> 6 | 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/children.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '../../islands/Counter' 2 | 3 | const AsyncChild = async () => { 4 | return Async child 5 | } 6 | 7 | export default function Interaction() { 8 | return ( 9 | <> 10 | 11 |
12 |

Sync

13 | Sync child 14 |
15 |
16 | 17 | 18 | 19 | 20 |
Child Counter
21 | 22 |
23 |

Child

24 |
25 |
26 |
27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, ErrorBoundary } from 'hono/jsx' 2 | import Counter from '../../islands/Counter' 3 | 4 | const SuspenseChild = async () => { 5 | await new Promise((resolve) => setTimeout(() => resolve(), 500)) 6 | return Suspense child 7 | } 8 | 9 | const SuspenseFailureChild = async () => { 10 | throw new Error('Suspense failure') 11 | return Suspense child 12 | } 13 | 14 | export default function Interaction() { 15 | return ( 16 | <> 17 | 18 | Something went wrong}> 19 | Loading...}> 20 | 21 | 22 | 23 | 24 | 25 | 28 | Something went wrong 29 | 30 | } 31 | > 32 | Loading...}> 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/index.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '../../islands/Counter' 2 | import { NamedCounter } from '../../islands/NamedCounter' 3 | 4 | export default function Interaction() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | 11 | 12 | }> 13 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/nested.tsx: -------------------------------------------------------------------------------- 1 | import CounterCard from '../../components/CounterCard' 2 | 3 | export default function Interaction() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/suspense-islands.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'hono/jsx' 2 | import Counter from '../../islands/Counter' 3 | 4 | const SuspenseChild = async () => { 5 | const initial = await new Promise((resolve) => setTimeout(() => resolve(6), 500)) 6 | return ( 7 | 8 | Suspense Islands 9 | 10 | ) 11 | } 12 | 13 | export default function Interaction() { 14 | return ( 15 | Loading...}> 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/suspense-never.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'hono/jsx' 2 | import Counter from '../../islands/Counter' 3 | 4 | const SuspenseNeverChild = async () => { 5 | await new Promise(() => {}) // never resolves 6 | return Suspense child 7 | } 8 | 9 | export default function Interaction() { 10 | return ( 11 | <> 12 | 13 | Loading...}> 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /mocks/app/routes/interaction/suspense.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'hono/jsx' 2 | import Counter from '../../islands/Counter' 3 | 4 | const SuspenseChild = async () => { 5 | await new Promise((resolve) => setTimeout(() => resolve(), 500)) 6 | return Suspense child 7 | } 8 | 9 | export default function Interaction() { 10 | return ( 11 | <> 12 | 13 | Loading...}> 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /mocks/app/routes/non-interactive.tsx: -------------------------------------------------------------------------------- 1 | import NotIsland from '../components/islands/not-island' 2 | 3 | export default function NonInteractive() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /mocks/app/routes/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute((c) => { 4 | return c.notFound() 5 | }) 6 | -------------------------------------------------------------------------------- /mocks/app/routes/post.mdx: -------------------------------------------------------------------------------- 1 | # Hello MDX -------------------------------------------------------------------------------- /mocks/app/routes/throw_error.tsx: -------------------------------------------------------------------------------- 1 | import { createRoute } from '../../../src/factory' 2 | 3 | export default createRoute(() => { 4 | throw new Error('Foo') 5 | }) 6 | -------------------------------------------------------------------------------- /mocks/app/server.ts: -------------------------------------------------------------------------------- 1 | import { showRoutes } from 'hono/dev' 2 | import { logger } from 'hono/logger' 3 | import { createApp } from '../../src/server' 4 | 5 | const ROUTES = import.meta.glob('./routes/**/[a-z[-][a-z[_-]*.(tsx|ts)', { 6 | eager: true, 7 | }) 8 | 9 | const RENDERER = import.meta.glob('./routes/**/_renderer.tsx', { 10 | eager: true, 11 | }) 12 | 13 | const NOT_FOUND = import.meta.glob('./routes/**/_404.(ts|tsx)', { 14 | eager: true, 15 | }) 16 | 17 | const ERROR = import.meta.glob('./routes/**/_error.(ts|tsx)', { 18 | eager: true, 19 | }) 20 | 21 | const app = createApp({ 22 | // @ts-expect-error type is not specified 23 | ROUTES, 24 | // @ts-expect-error type is not specified 25 | RENDERER, 26 | // @ts-expect-error type is not specified 27 | NOT_FOUND, 28 | // @ts-expect-error type is not specified 29 | ERROR, 30 | root: './routes', 31 | init: (app) => { 32 | app.use(logger()) 33 | }, 34 | }) 35 | showRoutes(app, { 36 | verbose: true, 37 | }) 38 | 39 | export default app 40 | -------------------------------------------------------------------------------- /mocks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | } -------------------------------------------------------------------------------- /mocks/vite.config.ts: -------------------------------------------------------------------------------- 1 | import mdx from '@mdx-js/rollup' 2 | import { defineConfig } from 'vite' 3 | import path from 'path' 4 | import honox from '../src/vite' 5 | 6 | export default defineConfig({ 7 | resolve: { 8 | alias: { 9 | 'honox/vite': path.resolve(__dirname, '../src/vite'), 10 | }, 11 | }, 12 | plugins: [ 13 | honox({ 14 | entry: './app/server.ts', 15 | }), 16 | mdx({ 17 | jsxImportSource: 'hono/jsx', 18 | }), 19 | ], 20 | }) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "honox", 3 | "version": "0.1.41", 4 | "main": "dist/index.js", 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "tsup", 8 | "test": "bun typecheck && bun test:unit && bun test:integration && bun test:e2e", 9 | "test:unit": "vitest --run ./src", 10 | "test:integration": "bun test:integration:api && bun test:integration:apps", 11 | "test:integration:api": "vitest --run ./test-integration/api.test.ts", 12 | "test:integration:apps": "vitest --run -c ./test-integration/vitest.config.ts ./test-integration/apps.test.ts", 13 | "test:e2e": "playwright test -c ./test-e2e/playwright.config.ts ./test-e2e/e2e.test.ts", 14 | "typecheck": "tsc --noEmit", 15 | "build": "tsup && publint", 16 | "watch": "tsup --watch", 17 | "lint": "eslint src mocks test-integration test-e2e", 18 | "lint:fix": "eslint src mocks test-integration test-e2e --fix", 19 | "format": "prettier --check \"src/**/*.{js,ts}\" \"mocks/**/*.{js,ts}\" \"test-*/**/*.{js,ts}\"", 20 | "format:fix": "prettier --write \"src/**/*.{js,ts}\" \"mocks/**/*.{js,ts}\" \"test-*/**/*.{js,ts}\"", 21 | "prerelease": "bun run test && bun run build", 22 | "release": "np" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "import": "./dist/index.js" 31 | }, 32 | "./types": { 33 | "types": "./dist/types.d.ts", 34 | "import": "./dist/types.js" 35 | }, 36 | "./factory": { 37 | "types": "./dist/factory/index.d.ts", 38 | "import": "./dist/factory/index.js" 39 | }, 40 | "./server": { 41 | "types": "./dist/server/index.d.ts", 42 | "import": "./dist/server/index.js" 43 | }, 44 | "./server/base": { 45 | "types": "./dist/server/base.d.ts", 46 | "import": "./dist/server/base.js" 47 | }, 48 | "./client": { 49 | "types": "./dist/client/index.d.ts", 50 | "import": "./dist/client/index.js" 51 | }, 52 | "./utils/*": { 53 | "types": "./dist/utils/*.d.ts", 54 | "import": "./dist/utils/*.js" 55 | }, 56 | "./vite": { 57 | "types": "./dist/vite/index.d.ts", 58 | "import": "./dist/vite/index.js" 59 | }, 60 | "./vite/client": { 61 | "types": "./dist/vite/client.d.ts", 62 | "import": "./dist/vite/client.js" 63 | }, 64 | "./vite/components": { 65 | "types": "./dist/vite/components/index.d.ts", 66 | "import": "./dist/vite/components/index.js" 67 | } 68 | }, 69 | "typesVersions": { 70 | "*": { 71 | "types": [ 72 | "./dist/types" 73 | ], 74 | "factory": [ 75 | "./dist/factory" 76 | ], 77 | "server": [ 78 | "./dist/server" 79 | ], 80 | "server/base": [ 81 | "./dist/server/base" 82 | ], 83 | "client": [ 84 | "./dist/client" 85 | ], 86 | "utils/*": [ 87 | "./dist/utils/*" 88 | ], 89 | "vite": [ 90 | "./dist/vite" 91 | ], 92 | "vite/client": [ 93 | "./dist/vite/client" 94 | ], 95 | "vite/components": [ 96 | "./dist/vite/components" 97 | ] 98 | } 99 | }, 100 | "author": "Yusuke Wada (https://github.com/yusukebe)", 101 | "license": "MIT", 102 | "repository": { 103 | "type": "git", 104 | "url": "https://github.com/honojs/honox.git" 105 | }, 106 | "publishConfig": { 107 | "registry": "https://registry.npmjs.org", 108 | "access": "public" 109 | }, 110 | "homepage": "https://hono.dev", 111 | "dependencies": { 112 | "@babel/generator": "7.25.6", 113 | "@babel/parser": "7.25.6", 114 | "@babel/traverse": "7.25.6", 115 | "@babel/types": "7.25.6", 116 | "@hono/vite-dev-server": "0.19.1", 117 | "jsonc-parser": "3.3.1", 118 | "precinct": "12.1.2" 119 | }, 120 | "overrides": { 121 | "@typescript-eslint/typescript-estree": "^8.19.0" 122 | }, 123 | "resolutions": { 124 | "@typescript-eslint/typescript-estree": "^8.19.0" 125 | }, 126 | "peerDependencies": { 127 | "hono": ">=4.*" 128 | }, 129 | "devDependencies": { 130 | "@hono/eslint-config": "^1.1.1", 131 | "@mdx-js/rollup": "^3.0.0", 132 | "@playwright/test": "^1.42.0", 133 | "@types/node": "^20.10.5", 134 | "eslint": "^9.23.0", 135 | "glob": "^10.3.10", 136 | "happy-dom": "^15.11.6", 137 | "hono": "4.4.13", 138 | "np": "^10.2.0", 139 | "prettier": "^3.1.1", 140 | "publint": "^0.2.7", 141 | "tsup": "^8.1.0", 142 | "typescript": "^5.3.3", 143 | "vite": "^5.4.12", 144 | "vite-tsconfig-paths": "^5.1.4", 145 | "vitest": "^3.0.3" 146 | }, 147 | "engines": { 148 | "node": ">=18.14.1" 149 | }, 150 | "optionalDependencies": { 151 | "@rollup/rollup-linux-x64-gnu": "^4.9.6" 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import { render, createElement as createElementHono } from 'hono/jsx/dom' 2 | import { 3 | COMPONENT_EXPORT, 4 | COMPONENT_NAME, 5 | DATA_HONO_TEMPLATE, 6 | DATA_SERIALIZED_PROPS, 7 | } from '../constants.js' 8 | import type { 9 | CreateChildren, 10 | CreateElement, 11 | Hydrate, 12 | HydrateComponent, 13 | TriggerHydration, 14 | } from '../types.js' 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | type FileCallback = () => Promise>> 18 | 19 | export type ClientOptions = { 20 | hydrate?: Hydrate 21 | createElement?: CreateElement 22 | /** 23 | * Create "children" attribute of a component from a list of child nodes 24 | */ 25 | createChildren?: CreateChildren 26 | /** 27 | * Trigger hydration on your own 28 | */ 29 | triggerHydration?: TriggerHydration 30 | ISLAND_FILES?: Record Promise> 31 | /** 32 | * @deprecated 33 | */ 34 | island_root?: string 35 | } 36 | 37 | export const createClient = async (options?: ClientOptions) => { 38 | const FILES = options?.ISLAND_FILES ?? { 39 | ...import.meta.glob('/app/islands/**/[a-zA-Z0-9-]+.tsx'), 40 | ...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.tsx'), 41 | ...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.tsx'), 42 | } 43 | 44 | const hydrateComponent: HydrateComponent = async (document) => { 45 | const filePromises = Object.keys(FILES).map(async (filePath) => { 46 | const componentName = filePath 47 | const elements = document.querySelectorAll( 48 | `[${COMPONENT_NAME}="${componentName}"]:not([data-hono-hydrated])` 49 | ) 50 | if (elements) { 51 | const elementPromises = Array.from(elements).map(async (element) => { 52 | element.setAttribute('data-hono-hydrated', 'true') // mark as hydrated 53 | const exportName = element.getAttribute(COMPONENT_EXPORT) || 'default' 54 | 55 | const fileCallback = FILES[filePath] as FileCallback 56 | const file = await fileCallback() 57 | const Component = await file[exportName] 58 | 59 | const serializedProps = element.attributes.getNamedItem(DATA_SERIALIZED_PROPS)?.value 60 | const props = JSON.parse(serializedProps ?? '{}') as Record 61 | 62 | const hydrate = options?.hydrate ?? render 63 | const createElement = options?.createElement ?? createElementHono 64 | 65 | let maybeTemplate = element.childNodes[element.childNodes.length - 1] 66 | while (maybeTemplate?.nodeName === 'TEMPLATE') { 67 | const propKey = (maybeTemplate as HTMLElement).getAttribute(DATA_HONO_TEMPLATE) 68 | if (propKey == null) { 69 | break 70 | } 71 | 72 | let createChildren = options?.createChildren 73 | if (!createChildren) { 74 | const { buildCreateChildrenFn } = await import('./runtime') 75 | createChildren = buildCreateChildrenFn( 76 | createElement as CreateElement, 77 | async (name: string) => (await (FILES[`${name}`] as FileCallback)()).default 78 | ) 79 | } 80 | props[propKey] = await createChildren( 81 | (maybeTemplate as HTMLTemplateElement).content.childNodes 82 | ) 83 | 84 | maybeTemplate = maybeTemplate.previousSibling as ChildNode 85 | } 86 | 87 | const newElem = await createElement(Component, props) 88 | // @ts-expect-error default `render` cause a type error 89 | await hydrate(newElem, element) 90 | }) 91 | await Promise.all(elementPromises) 92 | } 93 | }) 94 | 95 | await Promise.all(filePromises) 96 | } 97 | 98 | const triggerHydration = 99 | options?.triggerHydration ?? 100 | (async (hydrateComponent) => { 101 | if (document.querySelector('template[id^="H:"], template[id^="E:"]')) { 102 | const { hydrateComponentHonoSuspense } = await import('./runtime') 103 | await hydrateComponentHonoSuspense(hydrateComponent) 104 | } 105 | 106 | await hydrateComponent(document) 107 | }) 108 | await triggerHydration?.(hydrateComponent) 109 | } 110 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export { createClient } from './client.js' 2 | export type { ClientOptions } from './client.js' 3 | -------------------------------------------------------------------------------- /src/client/runtime.test.ts: -------------------------------------------------------------------------------- 1 | import { buildCreateChildrenFn } from './runtime' 2 | 3 | describe('buildCreateChildrenFn', () => { 4 | it('should set key for children', async () => { 5 | const createElement = vi.fn() 6 | const importComponent = vi.fn() 7 | const createChildren = buildCreateChildrenFn(createElement, importComponent) 8 | 9 | const div = document.createElement('div') 10 | div.innerHTML = 'test
test2
' 11 | const result = await createChildren(div.childNodes) 12 | expect(createElement).toHaveBeenNthCalledWith(1, 'SPAN', { 13 | children: ['test'], 14 | key: 1, 15 | }) 16 | expect(createElement).toHaveBeenNthCalledWith(2, 'DIV', { 17 | children: ['test2'], 18 | key: 2, 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/client/runtime.ts: -------------------------------------------------------------------------------- 1 | import { Suspense, use } from 'hono/jsx/dom' 2 | import { COMPONENT_NAME, DATA_HONO_TEMPLATE, DATA_SERIALIZED_PROPS } from '../constants.js' 3 | import type { CreateChildren, CreateElement, HydrateComponent } from '../types.js' 4 | 5 | type ImportComponent = (name: string) => Promise 6 | export const buildCreateChildrenFn = ( 7 | createElement: CreateElement, 8 | importComponent: ImportComponent 9 | ): CreateChildren => { 10 | let keyIndex = 0 11 | const setChildrenFromTemplate = async (props: { children?: Node[] }, element: HTMLElement) => { 12 | const maybeTemplate = element.childNodes[element.childNodes.length - 1] 13 | if ( 14 | maybeTemplate?.nodeName === 'TEMPLATE' && 15 | (maybeTemplate as HTMLElement)?.getAttribute(DATA_HONO_TEMPLATE) !== null 16 | ) { 17 | props.children = await createChildren( 18 | (maybeTemplate as HTMLTemplateElement).content.childNodes 19 | ) 20 | } 21 | } 22 | const createElementFromHTMLElement = async (element: HTMLElement) => { 23 | const props = { 24 | children: await createChildren(element.childNodes), 25 | } as Record & { children: Node[] } 26 | const attributes = element.attributes 27 | for (let i = 0; i < attributes.length; i++) { 28 | props[attributes[i].name] = attributes[i].value 29 | } 30 | return createElement(element.nodeName, { 31 | key: ++keyIndex, 32 | ...props, 33 | }) 34 | } 35 | const createChildren = async (childNodes: NodeListOf): Promise => { 36 | const children = [] 37 | for (let i = 0; i < childNodes.length; i++) { 38 | const child = childNodes[i] as HTMLElement 39 | if (child.nodeType === 8) { 40 | // skip comments 41 | continue 42 | } else if (child.nodeType === 3) { 43 | // text node 44 | children.push(child.textContent) 45 | } else if (child.nodeName === 'TEMPLATE' && child.id.match(/(?:H|E):\d+/)) { 46 | const placeholderElement = document.createElement('hono-placeholder') 47 | placeholderElement.style.display = 'none' 48 | 49 | let resolve: (nodes: Node[]) => void 50 | const promise = new Promise((r) => (resolve = r)) 51 | 52 | // Suspense: replace content by `replaceWith` when resolved 53 | // ErrorBoundary: replace content by `replaceWith` when error 54 | child.replaceWith = (node: DocumentFragment) => { 55 | createChildren(node.childNodes).then(resolve) 56 | placeholderElement.remove() 57 | } 58 | 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | let fallback: any = [] 61 | 62 | // gather fallback content and find placeholder comment 63 | for ( 64 | // equivalent to i++ 65 | placeholderElement.appendChild(child); 66 | i < childNodes.length; 67 | i++ 68 | ) { 69 | const child = childNodes[i] 70 | if (child.nodeType === 8) { 71 | // or 72 | placeholderElement.appendChild(child) 73 | i-- 74 | break 75 | } else if (child.nodeType === 3) { 76 | fallback.push(child.textContent) 77 | } else { 78 | fallback.push(await createElementFromHTMLElement(child as HTMLElement)) 79 | } 80 | } 81 | 82 | // if already resolved or error, get content from added template element 83 | const fallbackTemplates = document.querySelectorAll( 84 | `[data-hono-target="${child.id}"]` 85 | ) 86 | if (fallbackTemplates.length > 0) { 87 | const fallbackTemplate = fallbackTemplates[fallbackTemplates.length - 1] 88 | fallback = await createChildren(fallbackTemplate.content.childNodes) 89 | } 90 | 91 | // if no content available, wait for ErrorBoundary fallback content 92 | if (fallback.length === 0 && child.id.startsWith('E:')) { 93 | let resolve: (nodes: Node[]) => void 94 | const promise = new Promise((r) => (resolve = r)) 95 | fallback = await createElement(Suspense, { 96 | fallback: [], 97 | children: [await createElement(() => use(promise), {})], 98 | }) 99 | placeholderElement.insertBefore = ((node: DocumentFragment) => { 100 | createChildren(node.childNodes).then(resolve) 101 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 | }) as any 103 | } 104 | 105 | // wait for content to be resolved by placeholderElement 106 | document.body.appendChild(placeholderElement) 107 | 108 | // render fallback content 109 | children.push( 110 | await createElement(Suspense, { 111 | fallback, 112 | children: [await createElement(() => use(promise), {})], 113 | }) 114 | ) 115 | } else { 116 | let component: Function | undefined = undefined 117 | const componentName = child.getAttribute(COMPONENT_NAME) 118 | if (componentName) { 119 | component = await importComponent(componentName) 120 | } 121 | if (component) { 122 | const props = JSON.parse(child.getAttribute(DATA_SERIALIZED_PROPS) || '{}') 123 | await setChildrenFromTemplate(props, child) 124 | children.push( 125 | await createElement(component, { 126 | key: ++keyIndex, 127 | ...props, 128 | }) 129 | ) 130 | } else { 131 | children.push(await createElementFromHTMLElement(child)) 132 | } 133 | } 134 | } 135 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 136 | return children as any 137 | } 138 | 139 | return createChildren 140 | } 141 | 142 | export const hydrateComponentHonoSuspense = async (hydrateComponent: HydrateComponent) => { 143 | const templates = new Set() 144 | const observerTargets = new Set() 145 | document.querySelectorAll('template[id^="H:"], template[id^="E:"]').forEach((template) => { 146 | if (template.parentElement) { 147 | templates.add(template) 148 | observerTargets.add(template.parentElement) 149 | } 150 | }) 151 | 152 | if (observerTargets.size === 0) { 153 | return 154 | } 155 | 156 | const observer = new MutationObserver((mutations) => { 157 | const targets = new Set() 158 | mutations.forEach((mutation) => { 159 | if (mutation.target instanceof Element) { 160 | targets.add(mutation.target) 161 | mutation.removedNodes.forEach((node) => { 162 | templates.delete(node as Element) 163 | }) 164 | } 165 | }) 166 | targets.forEach((target) => { 167 | hydrateComponent(target) 168 | }) 169 | 170 | if (templates.size === 0) { 171 | // all templates have been hydrated 172 | observer.disconnect() 173 | } 174 | }) 175 | observerTargets.forEach((target) => { 176 | observer.observe(target, { childList: true }) 177 | }) 178 | } 179 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const COMPONENT_NAME = 'component-name' 2 | export const COMPONENT_EXPORT = 'component-export' 3 | export const DATA_SERIALIZED_PROPS = 'data-serialized-props' 4 | export const DATA_HONO_TEMPLATE = 'data-hono-template' 5 | export const IMPORTING_ISLANDS_ID = '__importing_islands' as const 6 | -------------------------------------------------------------------------------- /src/factory/factory.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'hono' 2 | import { Hono } from 'hono' 3 | import { createFactory } from 'hono/factory' 4 | 5 | const factory = createFactory() 6 | export const createRoute = factory.createHandlers 7 | export const createHono = () => { 8 | return new Hono() 9 | } 10 | -------------------------------------------------------------------------------- /src/factory/index.ts: -------------------------------------------------------------------------------- 1 | export { createRoute, createHono } from './factory.js' 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Export nothing 2 | -------------------------------------------------------------------------------- /src/server/base.ts: -------------------------------------------------------------------------------- 1 | export { createApp } from './server.js' 2 | -------------------------------------------------------------------------------- /src/server/components/has-islands.tsx: -------------------------------------------------------------------------------- 1 | import { IMPORTING_ISLANDS_ID } from '../../constants.js' 2 | import { contextStorage } from '../context-storage.js' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export const HasIslands = ({ children }: { children: any }): any => { 6 | const c = contextStorage.getStore() 7 | if (!c) { 8 | throw new Error('No context found') 9 | } 10 | return <>{c.get(IMPORTING_ISLANDS_ID) && children} 11 | } 12 | -------------------------------------------------------------------------------- /src/server/components/index.ts: -------------------------------------------------------------------------------- 1 | export { HasIslands } from './has-islands.js' 2 | export { Script } from './script.js' 3 | export { Link } from './link.js' 4 | -------------------------------------------------------------------------------- /src/server/components/link.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'hono/jsx' 2 | import type { JSX } from 'hono/jsx/jsx-runtime' 3 | import type { Manifest } from 'vite' 4 | import { ensureTrailngSlash } from '../utils/path.js' 5 | 6 | type Options = { manifest?: Manifest; prod?: boolean } & JSX.IntrinsicElements['link'] 7 | 8 | export const Link: FC = (options) => { 9 | let { href, prod, manifest, ...rest } = options 10 | if (href) { 11 | if (prod ?? import.meta.env.PROD) { 12 | if (!manifest) { 13 | const MANIFEST = import.meta.glob<{ default: Manifest }>('/dist/.vite/manifest.json', { 14 | eager: true, 15 | }) 16 | for (const [, manifestFile] of Object.entries(MANIFEST)) { 17 | if (manifestFile['default']) { 18 | manifest = manifestFile['default'] 19 | break 20 | } 21 | } 22 | } 23 | if (manifest) { 24 | const assetInManifest = manifest[href.replace(/^\//, '')] 25 | if (assetInManifest) { 26 | if (href.startsWith('/')) { 27 | return ( 28 | 32 | ) 33 | } 34 | 35 | return 36 | } 37 | } 38 | return <> 39 | } else { 40 | return 41 | } 42 | } 43 | 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /src/server/components/script.tsx: -------------------------------------------------------------------------------- 1 | import type { Manifest } from 'vite' 2 | import { ensureTrailngSlash } from '../utils/path.js' 3 | import { HasIslands } from './has-islands.js' 4 | 5 | type Options = { 6 | src: string 7 | async?: boolean 8 | prod?: boolean 9 | manifest?: Manifest 10 | nonce?: string 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | export const Script = (options: Options): any => { 15 | const src = options.src 16 | if (options.prod ?? import.meta.env.PROD) { 17 | let manifest: Manifest | undefined = options.manifest 18 | if (!manifest) { 19 | const MANIFEST = import.meta.glob<{ default: Manifest }>('/dist/.vite/manifest.json', { 20 | eager: true, 21 | }) 22 | for (const [, manifestFile] of Object.entries(MANIFEST)) { 23 | if (manifestFile['default']) { 24 | manifest = manifestFile['default'] 25 | break 26 | } 27 | } 28 | } 29 | if (manifest) { 30 | const scriptInManifest = manifest[src.replace(/^\//, '')] 31 | if (scriptInManifest) { 32 | return ( 33 | 34 | 40 | 41 | ) 42 | } 43 | } 44 | return <> 45 | } else { 46 | return 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/server/context-storage.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono' 2 | import { AsyncLocalStorage } from 'node:async_hooks' 3 | export const contextStorage = new AsyncLocalStorage() 4 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export { createApp } from './with-defaults.js' 2 | export type { ServerOptions } from './server.js' 3 | export * from './components/index.js' 4 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Hono } from 'hono' 3 | import type { Env, ErrorHandler, MiddlewareHandler, NotFoundHandler } from 'hono' 4 | import { createMiddleware } from 'hono/factory' 5 | import type { H } from 'hono/types' 6 | import { IMPORTING_ISLANDS_ID } from '../constants.js' 7 | import { contextStorage } from './context-storage.js' 8 | import { 9 | filePathToPath, 10 | groupByDirectory, 11 | listByDirectory, 12 | sortDirectoriesByDepth, 13 | } from './utils/file.js' 14 | 15 | const NOTFOUND_FILENAME = '_404.tsx' 16 | const ERROR_FILENAME = '_error.tsx' 17 | const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] as const 18 | 19 | type HasIslandFile = { 20 | [key in typeof IMPORTING_ISLANDS_ID]?: boolean 21 | } 22 | 23 | type InnerMeta = {} & HasIslandFile 24 | 25 | type AppFile = { default: Hono } & InnerMeta 26 | 27 | type RouteFile = { 28 | default?: Function 29 | } & { [M in (typeof METHODS)[number]]?: H[] } & InnerMeta 30 | 31 | type RendererFile = { default: MiddlewareHandler } & InnerMeta 32 | type NotFoundFile = { default: NotFoundHandler } & InnerMeta 33 | type ErrorFile = { default: ErrorHandler } & InnerMeta 34 | type MiddlewareFile = { default: MiddlewareHandler[] } 35 | 36 | type InitFunction = (app: Hono) => void 37 | 38 | type BaseServerOptions = { 39 | ROUTES: Record 40 | RENDERER: Record 41 | NOT_FOUND: Record 42 | ERROR: Record 43 | MIDDLEWARE: Record 44 | root: string 45 | app?: Hono 46 | init?: InitFunction 47 | /** 48 | * Appends a trailing slash to URL if the route file is an index file, e.g., `index.tsx` or `index.mdx`. 49 | * @default false 50 | */ 51 | trailingSlash?: boolean 52 | } 53 | 54 | export type ServerOptions = Partial> 55 | 56 | type Variables = {} & HasIslandFile 57 | 58 | export const createApp = (options: BaseServerOptions): Hono => { 59 | const root = options.root 60 | const rootRegExp = new RegExp(`^${root}`) 61 | const getRootPath = (dir: string) => filePathToPath(dir.replace(rootRegExp, '')) 62 | 63 | const app = options.app ?? new Hono() 64 | const trailingSlash = options.trailingSlash ?? false 65 | 66 | // Share context by AsyncLocalStorage 67 | app.use(async function ShareContext(c, next) { 68 | await contextStorage.run(c, () => next()) 69 | }) 70 | 71 | if (options.init) { 72 | options.init(app) 73 | } 74 | 75 | // Not Found 76 | const NOT_FOUND_FILE = options.NOT_FOUND 77 | const notFoundMap = groupByDirectory(NOT_FOUND_FILE) 78 | 79 | // Error 80 | const ERROR_FILE = options.ERROR 81 | const errorMap = groupByDirectory(ERROR_FILE) 82 | 83 | // Renderer 84 | const RENDERER_FILE = options.RENDERER 85 | const rendererList = listByDirectory(RENDERER_FILE) 86 | 87 | // Middleware 88 | const MIDDLEWARE_FILE = options.MIDDLEWARE 89 | 90 | // Routes 91 | const ROUTES_FILE = options.ROUTES 92 | const routesMap = sortDirectoriesByDepth(groupByDirectory(ROUTES_FILE)) 93 | 94 | const getPaths = (currentDirectory: string, fileList: Record) => { 95 | let paths = fileList[currentDirectory] ?? [] 96 | 97 | const getChildPaths = (childDirectories: string[]) => { 98 | paths = fileList[childDirectories.join('/')] 99 | if (!paths) { 100 | childDirectories.pop() 101 | if (childDirectories.length) { 102 | getChildPaths(childDirectories) 103 | } 104 | } 105 | return paths ?? [] 106 | } 107 | 108 | const renderDirPaths = currentDirectory.split('/') 109 | paths = getChildPaths(renderDirPaths) 110 | paths.sort((a, b) => a.split('/').length - b.split('/').length) 111 | return paths 112 | } 113 | 114 | const errorHandlerMap: Record = {} 115 | 116 | for (const map of routesMap) { 117 | for (const [dir, content] of Object.entries(map)) { 118 | const subApp = new Hono<{ 119 | Variables: Variables 120 | }>() 121 | let hasIslandComponent = false 122 | 123 | const notFoundHandler = getNotFoundHandler(dir, notFoundMap) 124 | if (notFoundHandler) { 125 | subApp.use(async (c, next) => { 126 | await next() 127 | if (c.res.status === 404) { 128 | const notFoundResponse = await notFoundHandler(c) 129 | const res = new Response(notFoundResponse.body, { 130 | status: 404, 131 | headers: notFoundResponse.headers, 132 | }) 133 | c.res = res 134 | } 135 | }) 136 | } 137 | 138 | // Renderer 139 | const rendererPaths = getPaths(dir, rendererList) 140 | rendererPaths.map((path) => { 141 | const renderer = RENDERER_FILE[path] 142 | const importingIslands = renderer[IMPORTING_ISLANDS_ID] 143 | if (importingIslands) { 144 | hasIslandComponent = true 145 | } 146 | const rendererDefault = renderer.default 147 | if (rendererDefault) { 148 | subApp.all('*', rendererDefault) 149 | } 150 | }) 151 | 152 | const middlewareFile = Object.keys(MIDDLEWARE_FILE).find((x) => { 153 | const replacedDir = dir 154 | .replaceAll('[', '\\[') 155 | .replaceAll(']', '\\]') 156 | .replaceAll('(', '\\(') 157 | .replaceAll(')', '\\)') 158 | 159 | return new RegExp(replacedDir + '/_middleware.tsx?').test(x) 160 | }) 161 | 162 | if (middlewareFile) { 163 | const middleware = MIDDLEWARE_FILE[middlewareFile] 164 | if (middleware.default) { 165 | subApp.use(...middleware.default) 166 | } 167 | } 168 | 169 | for (const [filename, route] of Object.entries(content)) { 170 | const importingIslands = route[IMPORTING_ISLANDS_ID] 171 | const setInnerMeta = createMiddleware<{ 172 | Variables: Variables 173 | }>(async function innerMeta(c, next) { 174 | c.set(IMPORTING_ISLANDS_ID, importingIslands ? true : hasIslandComponent) 175 | await next() 176 | }) 177 | 178 | const routeDefault = route.default 179 | const path = filePathToPath(filename) 180 | 181 | // Instance of Hono 182 | if (routeDefault && 'fetch' in routeDefault) { 183 | subApp.use(setInnerMeta) 184 | subApp.route(path, routeDefault) 185 | } 186 | 187 | // export const POST = factory.createHandlers(...) 188 | for (const m of METHODS) { 189 | const handlers = (route as Record)[m] 190 | if (handlers) { 191 | subApp.on(m, path, setInnerMeta) 192 | subApp.on(m, path, ...handlers) 193 | } 194 | } 195 | 196 | // export default factory.createHandlers(...) 197 | if (routeDefault && Array.isArray(routeDefault)) { 198 | subApp.get(path, setInnerMeta) 199 | subApp.get(path, ...(routeDefault as H[])) 200 | } 201 | 202 | // export default function Helle() {} 203 | if (typeof routeDefault === 'function') { 204 | subApp.get(path, setInnerMeta) 205 | subApp.get(path, async (c) => { 206 | const result = await routeDefault(c) 207 | 208 | if (result instanceof Response) { 209 | return result 210 | } 211 | 212 | return c.render(result, route as any) 213 | }) 214 | } 215 | } 216 | 217 | // Get an error handler 218 | const errorHandler = getErrorHandler(dir, errorMap) 219 | if (errorHandler) { 220 | errorHandlerMap[dir] = errorHandler 221 | } 222 | 223 | // Apply an error handler 224 | for (const [path, errorHandler] of Object.entries(errorHandlerMap)) { 225 | const regExp = new RegExp(`^${path}`) 226 | if (regExp.test(dir) && errorHandler) { 227 | subApp.onError(errorHandler) 228 | } 229 | } 230 | 231 | let rootPath = getRootPath(dir) 232 | if (trailingSlash) { 233 | rootPath = /\/$/.test(rootPath) ? rootPath : rootPath + '/' 234 | } 235 | app.route(rootPath, subApp) 236 | } 237 | } 238 | 239 | /** 240 | * Set Not Found handlers 241 | */ 242 | for (const map of routesMap.reverse()) { 243 | const dir = Object.entries(map)[0][0] 244 | const subApp = new Hono<{ 245 | Variables: Variables 246 | }>() 247 | applyNotFound(subApp, dir, notFoundMap) 248 | const rootPath = getRootPath(dir) 249 | app.route(rootPath, subApp) 250 | } 251 | 252 | return app 253 | } 254 | 255 | function getNotFoundHandler(dir: string, map: Record>) { 256 | for (const [mapDir, content] of Object.entries(map)) { 257 | if (dir === mapDir) { 258 | const notFound = content[NOTFOUND_FILENAME] 259 | if (notFound) { 260 | return notFound.default 261 | } 262 | } 263 | } 264 | } 265 | 266 | function applyNotFound( 267 | app: Hono<{ 268 | Variables: Variables 269 | }>, 270 | dir: string, 271 | map: Record> 272 | ) { 273 | for (const [mapDir, content] of Object.entries(map)) { 274 | if (dir === mapDir) { 275 | const notFound = content[NOTFOUND_FILENAME] 276 | if (notFound) { 277 | const notFoundHandler = notFound.default 278 | const importingIslands = notFound[IMPORTING_ISLANDS_ID] 279 | if (importingIslands) { 280 | app.use('*', (c, next) => { 281 | c.set(IMPORTING_ISLANDS_ID, true) 282 | return next() 283 | }) 284 | } 285 | app.get('*', (c) => { 286 | c.status(404) 287 | return notFoundHandler(c) 288 | }) 289 | } 290 | } 291 | } 292 | } 293 | 294 | function getErrorHandler(dir: string, map: Record>) { 295 | for (const [mapDir, content] of Object.entries(map)) { 296 | if (dir === mapDir) { 297 | const errorFile = content[ERROR_FILENAME] 298 | if (errorFile) { 299 | const matchedErrorHandler = errorFile.default 300 | if (matchedErrorHandler) { 301 | const errorHandler: ErrorHandler = async (error, c) => { 302 | const importingIslands = errorFile[IMPORTING_ISLANDS_ID] 303 | if (importingIslands) { 304 | c.set(IMPORTING_ISLANDS_ID, importingIslands) 305 | } 306 | c.status(500) 307 | return matchedErrorHandler(error, c) 308 | } 309 | return errorHandler 310 | } 311 | } 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/server/utils/file.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filePathToPath, 3 | groupByDirectory, 4 | listByDirectory, 5 | pathToDirectoryPath, 6 | sortDirectoriesByDepth, 7 | } from './file' 8 | 9 | describe('filePathToPath', () => { 10 | it('Should return a correct path', () => { 11 | expect(filePathToPath('index.tsx')).toBe('/') 12 | expect(filePathToPath('index.get.tsx')).toBe('/index.get') 13 | expect(filePathToPath('about.tsx')).toBe('/about') 14 | expect(filePathToPath('about/index.tsx')).toBe('/about') 15 | expect(filePathToPath('about/me')).toBe('/about/me') 16 | expect(filePathToPath('about/me/index.tsx')).toBe('/about/me') 17 | expect(filePathToPath('about/me/address.tsx')).toBe('/about/me/address') 18 | 19 | expect(filePathToPath('/index.tsx')).toBe('/') 20 | expect(filePathToPath('/index.get.tsx')).toBe('/index.get') 21 | expect(filePathToPath('/about.tsx')).toBe('/about') 22 | expect(filePathToPath('/about/index.tsx')).toBe('/about') 23 | expect(filePathToPath('/about/me')).toBe('/about/me') 24 | expect(filePathToPath('/about/me/index.tsx')).toBe('/about/me') 25 | expect(filePathToPath('/about/me/address.tsx')).toBe('/about/me/address') 26 | 27 | expect(filePathToPath('/about/[name].tsx')).toBe('/about/:name') 28 | expect(filePathToPath('/about/[...foo].tsx')).toBe('/about/*') 29 | expect(filePathToPath('/about/[name]/address.tsx')).toBe('/about/:name/address') 30 | expect(filePathToPath('/about/[arg1]/[arg2]')).toBe('/about/:arg1/:arg2') 31 | 32 | expect(filePathToPath('/articles/(grouped1)/[slug]')).toBe('/articles/:slug') 33 | expect(filePathToPath('/articles/(grouped1)/[slug]/(grouped1)/edit')).toBe( 34 | '/articles/:slug/edit' 35 | ) 36 | }) 37 | }) 38 | 39 | describe('groupByDirectory', () => { 40 | const files = { 41 | '/app/routes/index.tsx': 'file1', 42 | '/app/routes/about.tsx': 'file2', 43 | '/app/routes/blog/index.tsx': 'file3', 44 | '/app/routes/blog/about.tsx': 'file4', 45 | '/app/routes/blog/posts/index.tsx': 'file5', 46 | '/app/routes/blog/posts/comments.tsx': 'file6', 47 | '/app/routes/articles/(content)/[slug].tsx': 'file7', 48 | } 49 | 50 | it('Should group by directories', () => { 51 | expect(groupByDirectory(files)).toEqual({ 52 | '/app/routes': { 53 | 'index.tsx': 'file1', 54 | 'about.tsx': 'file2', 55 | }, 56 | '/app/routes/blog': { 57 | 'index.tsx': 'file3', 58 | 'about.tsx': 'file4', 59 | }, 60 | '/app/routes/blog/posts': { 61 | 'index.tsx': 'file5', 62 | 'comments.tsx': 'file6', 63 | }, 64 | '/app/routes/articles/(content)': { 65 | '[slug].tsx': 'file7', 66 | }, 67 | }) 68 | }) 69 | }) 70 | 71 | describe('sortDirectoriesByDepth', () => { 72 | it('Should sort directories by the depth', () => { 73 | expect( 74 | sortDirectoriesByDepth({ 75 | '/dir': { 76 | 'index.tsx': 'file1', 77 | }, 78 | '/dir/blog/[id]': { 79 | 'index.tsx': 'file2', 80 | }, 81 | '/dir/blog/posts': { 82 | 'index.tsx': 'file3', 83 | }, 84 | '/dir/blog': { 85 | 'index.tsx': 'file4', 86 | }, 87 | }) 88 | ).toStrictEqual([ 89 | { 90 | '/dir': { 91 | 'index.tsx': 'file1', 92 | }, 93 | }, 94 | { 95 | '/dir/blog': { 96 | 'index.tsx': 'file4', 97 | }, 98 | }, 99 | { 100 | '/dir/blog/posts': { 101 | 'index.tsx': 'file3', 102 | }, 103 | }, 104 | { 105 | '/dir/blog/[id]': { 106 | 'index.tsx': 'file2', 107 | }, 108 | }, 109 | ]) 110 | }) 111 | }) 112 | 113 | describe('listByDirectory', () => { 114 | it('Should list files by their directory', () => { 115 | const files = { 116 | '/app/routes/blog/posts/_renderer.tsx': 'foo3', 117 | '/app/routes/_renderer.tsx': 'foo', 118 | '/app/routes/blog/_renderer.tsx': 'foo2', 119 | } 120 | 121 | const result = listByDirectory(files) 122 | 123 | expect(result).toEqual({ 124 | '/app/routes': ['/app/routes/_renderer.tsx'], 125 | '/app/routes/blog': ['/app/routes/blog/_renderer.tsx', '/app/routes/_renderer.tsx'], 126 | '/app/routes/blog/posts': [ 127 | '/app/routes/blog/posts/_renderer.tsx', 128 | '/app/routes/blog/_renderer.tsx', 129 | '/app/routes/_renderer.tsx', 130 | ], 131 | }) 132 | }) 133 | }) 134 | 135 | describe('pathToDirectoryPath', () => { 136 | it('Should return the directory path', () => { 137 | expect(pathToDirectoryPath('/')).toBe('/') 138 | expect(pathToDirectoryPath('/about.tsx')).toBe('/') 139 | expect(pathToDirectoryPath('/posts/index.tsx')).toBe('/posts/') 140 | expect(pathToDirectoryPath('/posts/authors/index.tsx')).toBe('/posts/authors/') 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/server/utils/file.ts: -------------------------------------------------------------------------------- 1 | export const filePathToPath = (filePath: string) => { 2 | filePath = filePath 3 | .replace(/\.tsx?$/g, '') 4 | .replace(/\.mdx?$/g, '') 5 | .replace(/^\/?index$/, '/') // `/index` 6 | .replace(/\/index$/, '') // `/about/index` 7 | .replace(/\[\.{3}.+\]/, '*') 8 | .replace(/\((.+?)\)/g, '') 9 | .replace(/\[(.+?)\]/g, ':$1') 10 | .replace(/\/\//g, '/') 11 | return /^\//.test(filePath) ? filePath : '/' + filePath 12 | } 13 | 14 | /* 15 | /app/routes/_error.tsx 16 | /app/routes/_404.tsx 17 | => { 18 | '/app/routes': { 19 | '/app/routes/_error.tsx': file, 20 | '/app/routes/_404.tsx': file 21 | } 22 | ... 23 | } 24 | */ 25 | export const groupByDirectory = (files: Record) => { 26 | const organizedFiles = {} as Record> 27 | 28 | for (const [path, content] of Object.entries(files)) { 29 | const pathParts = path.split('/') 30 | const fileName = pathParts.pop() 31 | const directory = pathParts.join('/') 32 | 33 | if (!organizedFiles[directory]) { 34 | organizedFiles[directory] = {} 35 | } 36 | 37 | if (fileName) { 38 | organizedFiles[directory][fileName] = content 39 | } 40 | } 41 | 42 | // Sort the files in each directory 43 | for (const [directory, files] of Object.entries(organizedFiles)) { 44 | const sortedEntries = Object.entries(files).sort(([keyA], [keyB]) => { 45 | if (keyA[0] === '[' && keyB[0] !== '[') { 46 | return 1 47 | } 48 | if (keyA[0] !== '[' && keyB[0] === '[') { 49 | return -1 50 | } 51 | return keyA.localeCompare(keyB) 52 | }) 53 | 54 | organizedFiles[directory] = Object.fromEntries(sortedEntries) 55 | } 56 | 57 | return organizedFiles 58 | } 59 | 60 | export const sortDirectoriesByDepth = (directories: Record) => { 61 | const sortedKeys = Object.keys(directories).sort((a, b) => { 62 | const depthA = a.split('/').length 63 | const depthB = b.split('/').length 64 | return depthA - depthB || b.localeCompare(a) 65 | }) 66 | 67 | return sortedKeys.map((key) => ({ 68 | [key]: directories[key], 69 | })) as Record[] 70 | } 71 | 72 | /* 73 | /app/routes/_renderer.tsx 74 | /app/routes/blog/_renderer.tsx 75 | => { 76 | '/app/routes': ['/app/routes/_renderer.tsx'] 77 | '/app/routes/blog': ['/app/routes/blog/_renderer.tsx', '/app/routes/_.tsx'] 78 | } 79 | */ 80 | export const listByDirectory = (files: Record) => { 81 | const organizedFiles = {} as Record 82 | 83 | for (const path of Object.keys(files)) { 84 | const pathParts = path.split('/') 85 | pathParts.pop() // extract file 86 | const directory = pathParts.join('/') 87 | 88 | if (!organizedFiles[directory]) { 89 | organizedFiles[directory] = [] 90 | } 91 | if (!organizedFiles[directory].includes(path)) { 92 | organizedFiles[directory].push(path) 93 | } 94 | } 95 | 96 | const directories = Object.keys(organizedFiles).sort((a, b) => b.length - a.length) 97 | for (const dir of directories) { 98 | for (const subDir of directories) { 99 | if (subDir.startsWith(dir) && subDir !== dir) { 100 | const uniqueFiles = new Set([...organizedFiles[subDir], ...organizedFiles[dir]]) 101 | organizedFiles[subDir] = [...uniqueFiles] 102 | } 103 | } 104 | } 105 | 106 | return organizedFiles 107 | } 108 | 109 | export const pathToDirectoryPath = (path: string) => { 110 | const dirPath = path.replace(/[^\/]+$/, '') 111 | return dirPath 112 | } 113 | -------------------------------------------------------------------------------- /src/server/utils/path.test.ts: -------------------------------------------------------------------------------- 1 | import { ensureTrailngSlash } from './path' 2 | 3 | describe('ensureTrailngSlash', () => { 4 | it('Should ensure trailing slash', () => { 5 | expect(ensureTrailngSlash('./')).toBe('./') 6 | expect(ensureTrailngSlash('/')).toBe('/') 7 | expect(ensureTrailngSlash('/subdir')).toBe('/subdir/') 8 | expect(ensureTrailngSlash('/subdir/')).toBe('/subdir/') 9 | expect(ensureTrailngSlash('https://example.com')).toBe('https://example.com/') 10 | expect(ensureTrailngSlash('https://example.com/subdir')).toBe('https://example.com/subdir/') 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/server/utils/path.ts: -------------------------------------------------------------------------------- 1 | export const ensureTrailngSlash = (path: string) => { 2 | return path.endsWith('/') ? path : path + '/' 3 | } 4 | -------------------------------------------------------------------------------- /src/server/with-defaults.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'hono' 2 | import { createApp as baseCreateApp } from './server.js' 3 | import type { ServerOptions } from './server.js' 4 | 5 | export const createApp = (options?: ServerOptions) => { 6 | const newOptions = { 7 | root: options?.root ?? '/app/routes', 8 | app: options?.app, 9 | init: options?.init, 10 | trailingSlash: options?.trailingSlash, 11 | NOT_FOUND: 12 | options?.NOT_FOUND ?? 13 | import.meta.glob('/app/routes/**/_404.(ts|tsx)', { 14 | eager: true, 15 | }), 16 | ERROR: 17 | options?.ERROR ?? 18 | import.meta.glob('/app/routes/**/_error.(ts|tsx)', { 19 | eager: true, 20 | }), 21 | RENDERER: 22 | options?.RENDERER ?? 23 | import.meta.glob('/app/routes/**/_renderer.tsx', { 24 | eager: true, 25 | }), 26 | MIDDLEWARE: 27 | options?.MIDDLEWARE ?? 28 | import.meta.glob('/app/routes/**/_middleware.(ts|tsx)', { 29 | eager: true, 30 | }), 31 | ROUTES: 32 | options?.ROUTES ?? 33 | import.meta.glob( 34 | [ 35 | '/app/routes/**/!(_*|-*|$*|*.test|*.spec).(ts|tsx|md|mdx)', 36 | '/app/routes/.well-known/**/!(_*|-*|$*|*.test|*.spec).(ts|tsx|md|mdx)', 37 | '!/app/routes/**/-*/**/*', 38 | ], 39 | { 40 | eager: true, 41 | } 42 | ), 43 | } 44 | 45 | return baseCreateApp(newOptions) 46 | } 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | /** JSX */ 4 | export type CreateElement = (type: any, props: any) => Node | Promise 5 | export type Hydrate = (children: Node, parent: Element) => void | Promise 6 | export type CreateChildren = (childNodes: NodeListOf) => Node[] | Promise 7 | export type HydrateComponent = (doc: { 8 | querySelectorAll: typeof document.querySelectorAll 9 | }) => Promise 10 | export type TriggerHydration = (trigger: HydrateComponent) => void 11 | -------------------------------------------------------------------------------- /src/vite/client.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite' 2 | 3 | export type ClientOptions = { 4 | jsxImportSource?: string 5 | assetsDir?: string 6 | input?: string[] 7 | } 8 | 9 | export const defaultOptions: ClientOptions = { 10 | jsxImportSource: 'hono/jsx/dom', 11 | assetsDir: 'static', 12 | input: [], 13 | } 14 | 15 | function client(options?: ClientOptions): Plugin { 16 | return { 17 | name: 'honox-vite-client', 18 | apply: (_config, { command, mode }) => { 19 | if (command === 'build' && mode === 'client') { 20 | return true 21 | } 22 | return false 23 | }, 24 | config: () => { 25 | const input = options?.input ?? defaultOptions.input ?? [] 26 | return { 27 | build: { 28 | rollupOptions: { 29 | input: ['/app/client.ts', ...input], 30 | }, 31 | assetsDir: options?.assetsDir ?? defaultOptions.assetsDir, 32 | manifest: true, 33 | }, 34 | esbuild: { 35 | jsxImportSource: options?.jsxImportSource ?? defaultOptions.jsxImportSource, 36 | }, 37 | } 38 | }, 39 | } 40 | } 41 | 42 | export default client 43 | -------------------------------------------------------------------------------- /src/vite/components/honox-island.test.tsx: -------------------------------------------------------------------------------- 1 | import { HonoXIsland } from './honox-island' 2 | 3 | const TestComponent = () =>
Test
4 | 5 | describe('HonoXIsland', () => { 6 | it('should set key for children', () => { 7 | const element = HonoXIsland({ 8 | componentName: 'Test', 9 | componentExport: 'Test', 10 | Component: () =>
Test
, 11 | props: { 12 | children: , 13 | }, 14 | }) 15 | // XXX: tested by internal implementation 16 | expect((element as any).children[1][0].key).toBe('children') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/vite/components/honox-island.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, isValidElement } from 'hono/jsx' 2 | import { 3 | COMPONENT_NAME, 4 | COMPONENT_EXPORT, 5 | DATA_SERIALIZED_PROPS, 6 | DATA_HONO_TEMPLATE, 7 | } from '../../constants' 8 | 9 | const inIsland = Symbol() 10 | const inChildren = Symbol() 11 | const IslandContext = createContext({ 12 | [inIsland]: false, 13 | [inChildren]: false, 14 | }) 15 | 16 | const isElementPropValue = (value: unknown): boolean => 17 | Array.isArray(value) 18 | ? value.some(isElementPropValue) 19 | : typeof value === 'object' && isValidElement(value) 20 | 21 | export const HonoXIsland = ({ 22 | componentName, 23 | componentExport, 24 | Component, 25 | props, 26 | }: { 27 | componentName: string 28 | componentExport: string 29 | Component: Function 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | props: any 32 | }) => { 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | const elementProps: Record = {} 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | const restProps: Record = {} 37 | for (const key in props) { 38 | const value = props[key] 39 | if (isElementPropValue(value)) { 40 | elementProps[key] = value 41 | } else { 42 | restProps[key] = value 43 | } 44 | } 45 | 46 | const islandState = useContext(IslandContext) 47 | return islandState[inChildren] || !islandState[inIsland] ? ( 48 | // top-level or slot content 49 | 56 | 57 | 58 | 59 | {Object.entries(elementProps).map(([key, children]) => ( 60 | 65 | ))} 66 | 67 | ) : ( 68 | // nested component 69 | 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/vite/components/index.ts: -------------------------------------------------------------------------------- 1 | export { HonoXIsland } from './honox-island.js' 2 | -------------------------------------------------------------------------------- /src/vite/index.ts: -------------------------------------------------------------------------------- 1 | import devServer, { defaultOptions as devServerDefaultOptions } from '@hono/vite-dev-server' 2 | import type { DevServerOptions } from '@hono/vite-dev-server' 3 | import type { PluginOption } from 'vite' 4 | import path from 'path' 5 | import type { ClientOptions } from './client.js' 6 | import client from './client.js' 7 | import { injectImportingIslands } from './inject-importing-islands.js' 8 | import { islandComponents } from './island-components.js' 9 | import type { IslandComponentsOptions } from './island-components.js' 10 | import { restartOnAddUnlink } from './restart-on-add-unlink.js' 11 | 12 | type Options = { 13 | islands?: boolean 14 | entry?: string 15 | devServer?: DevServerOptions 16 | islandComponents?: IslandComponentsOptions 17 | client?: ClientOptions 18 | external?: string[] 19 | } 20 | 21 | export const defaultOptions: Options = { 22 | islands: true, 23 | entry: path.join(process.cwd(), './app/server.ts'), 24 | } 25 | 26 | function honox(options?: Options): PluginOption[] { 27 | const plugins: PluginOption[] = [] 28 | 29 | const entry = options?.entry ?? defaultOptions.entry 30 | 31 | plugins.push( 32 | devServer({ 33 | entry, 34 | exclude: [ 35 | ...devServerDefaultOptions.exclude, 36 | /^\/app\/.+\.tsx?/, 37 | /^\/favicon.ico/, 38 | /^\/static\/.+/, 39 | ], 40 | handleHotUpdate: () => { 41 | return undefined 42 | }, 43 | ...options?.devServer, 44 | }) 45 | ) 46 | 47 | if (options?.islands !== false) { 48 | plugins.push(islandComponents(options?.islandComponents)) 49 | } 50 | 51 | plugins.push(injectImportingIslands()) 52 | plugins.push(restartOnAddUnlink()) 53 | plugins.push(client(options?.client)) 54 | 55 | return [ 56 | { 57 | name: 'honox-vite-config', 58 | config: () => { 59 | return { 60 | ssr: { 61 | noExternal: true, 62 | }, 63 | } 64 | }, 65 | }, 66 | ...plugins, 67 | ] 68 | } 69 | 70 | export { devServerDefaultOptions, islandComponents } 71 | 72 | export default honox 73 | -------------------------------------------------------------------------------- /src/vite/inject-importing-islands.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error don't use types 2 | import _generate from '@babel/generator' 3 | import { parse } from '@babel/parser' 4 | import precinct from 'precinct' 5 | import { normalizePath } from 'vite' 6 | import type { Plugin, ResolvedConfig } from 'vite' 7 | import { readFile } from 'fs/promises' 8 | import path from 'path' 9 | import { IMPORTING_ISLANDS_ID } from '../constants.js' 10 | import { matchIslandComponentId } from './utils/path.js' 11 | 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const generate = (_generate.default as typeof _generate) ?? _generate 15 | 16 | type InjectImportingIslandsOptions = { 17 | appDir?: string 18 | islandDir?: string 19 | } 20 | 21 | type ResolvedId = { 22 | id: string 23 | } 24 | 25 | export async function injectImportingIslands( 26 | options?: InjectImportingIslandsOptions 27 | ): Promise { 28 | let appPath = '' 29 | const islandDir = options?.islandDir ?? '/app/islands' 30 | let root = '' 31 | let config: ResolvedConfig 32 | const resolvedCache = new Map() 33 | const cache: Record = {} 34 | 35 | const walkDependencyTree: ( 36 | baseFile: string, 37 | resolve: (path: string, importer?: string) => Promise, 38 | dependencyFile?: ResolvedId | string 39 | ) => Promise = async (baseFile: string, resolve, dependencyFile?) => { 40 | const depPath = dependencyFile 41 | ? typeof dependencyFile === 'string' 42 | ? path.join(path.dirname(baseFile), dependencyFile) + '.tsx' 43 | : dependencyFile['id'] 44 | : baseFile 45 | const deps = [depPath] 46 | 47 | try { 48 | if (!cache[depPath]) { 49 | cache[depPath] = (await readFile(depPath, { flag: '' })).toString() 50 | } 51 | 52 | const currentFileDeps = precinct(cache[depPath], { 53 | type: 'tsx', 54 | }) as string[] 55 | 56 | const childDeps = await Promise.all( 57 | currentFileDeps.map(async (file) => { 58 | const resolvedId = await resolve(file, baseFile) 59 | return await walkDependencyTree(depPath, resolve, resolvedId ?? file) 60 | }) 61 | ) 62 | deps.push(...childDeps.flat()) 63 | return deps 64 | } catch { 65 | // file does not exist or is a directory 66 | return deps 67 | } 68 | } 69 | 70 | return { 71 | name: 'inject-importing-islands', 72 | configResolved: async (resolveConfig) => { 73 | config = resolveConfig 74 | appPath = path.join(config.root, options?.appDir ?? '/app') 75 | root = config.root 76 | }, 77 | async transform(sourceCode, id) { 78 | if (!path.resolve(id).startsWith(appPath)) { 79 | return 80 | } 81 | 82 | const resolve = async (importee: string, importer?: string) => { 83 | if (resolvedCache.has(importee)) { 84 | return this.resolve(importee) 85 | } 86 | const resolvedId = await this.resolve(importee, importer) 87 | // Cache to prevent infinite loops in recursive calls. 88 | resolvedCache.set(importee, true) 89 | return resolvedId 90 | } 91 | 92 | const hasIslandsImport = ( 93 | await Promise.all( 94 | (await walkDependencyTree(id, resolve)).flat().map(async (x) => { 95 | const rootPath = '/' + path.relative(root, normalizePath(x)).replace(/\\/g, '/') 96 | return matchIslandComponentId(rootPath, islandDir) 97 | }) 98 | ) 99 | ).some((matched) => matched) 100 | 101 | if (!hasIslandsImport) { 102 | return 103 | } 104 | 105 | const ast = parse(sourceCode, { 106 | sourceType: 'module', 107 | plugins: ['jsx', 'typescript'], 108 | }) 109 | 110 | const hasIslandsNode = { 111 | type: 'ExportNamedDeclaration', 112 | declaration: { 113 | type: 'VariableDeclaration', 114 | declarations: [ 115 | { 116 | type: 'VariableDeclarator', 117 | id: { type: 'Identifier', name: IMPORTING_ISLANDS_ID }, 118 | init: { type: 'BooleanLiteral', value: true }, 119 | }, 120 | ], 121 | kind: 'const', 122 | }, 123 | } 124 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 125 | ast.program.body.push(hasIslandsNode as any) 126 | 127 | const output = generate(ast, {}, sourceCode) 128 | return { 129 | code: output.code, 130 | map: output.map, 131 | } 132 | }, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/vite/island-components.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import os from 'os' 3 | import path from 'path' 4 | import { islandComponents, transformJsxTags } from './island-components.js' 5 | 6 | describe('transformJsxTags', () => { 7 | it('Should add component-wrapper and component-name attribute', () => { 8 | const code = `export default function Badge() { 9 | return

Hello

10 | }` 11 | const result = transformJsxTags(code, 'Badge.tsx') 12 | expect(result).toBe( 13 | `import { HonoXIsland } from "honox/vite/components"; 14 | const BadgeOriginal = function () { 15 | return

Hello

; 16 | }; 17 | const WrappedBadge = function (props) { 18 | return import.meta.env.SSR ? : ; 19 | }; 20 | export default WrappedBadge;` 21 | ) 22 | }) 23 | 24 | it('Should add component-wrapper and component-name attribute for named export', () => { 25 | const code = `function Badge() { 26 | return

Hello

27 | } 28 | export { Badge } 29 | ` 30 | const result = transformJsxTags(code, 'Badge.tsx') 31 | expect(result).toBe( 32 | `import { HonoXIsland } from "honox/vite/components"; 33 | function Badge() { 34 | return

Hello

; 35 | } 36 | const WrappedBadge = function (props) { 37 | return import.meta.env.SSR ? : ; 38 | }; 39 | export { WrappedBadge as Badge };` 40 | ) 41 | }) 42 | 43 | it('Should add component-wrapper and component-name attribute for named function', () => { 44 | const code = `export function Badge() { 45 | return

Hello

46 | } 47 | ` 48 | const result = transformJsxTags(code, 'Badge.tsx') 49 | expect(result).toBe( 50 | `import { HonoXIsland } from "honox/vite/components"; 51 | function Badge() { 52 | return

Hello

; 53 | } 54 | const WrappedBadge = function (props) { 55 | return import.meta.env.SSR ? : ; 56 | }; 57 | export { WrappedBadge as Badge };` 58 | ) 59 | }) 60 | 61 | it('Should add component-wrapper and component-name attribute for variable', () => { 62 | const code = `export const Badge = () => { 63 | return

Hello

64 | } 65 | ` 66 | const result = transformJsxTags(code, 'Badge.tsx') 67 | expect(result).toBe( 68 | `import { HonoXIsland } from "honox/vite/components"; 69 | const Badge = () => { 70 | return

Hello

; 71 | }; 72 | const WrappedBadge = function (props) { 73 | return import.meta.env.SSR ? : ; 74 | }; 75 | export { WrappedBadge as Badge };` 76 | ) 77 | }) 78 | 79 | it('Should not transform constant', () => { 80 | const code = `export const MAX = 10 81 | const MIN = 0 82 | export { MIN } 83 | ` 84 | const result = transformJsxTags(code, 'Badge.tsx') 85 | expect(result).toBe( 86 | `export const MAX = 10; 87 | const MIN = 0; 88 | export { MIN };` 89 | ) 90 | }) 91 | 92 | it('Should not transform if it is blank', () => { 93 | const code = transformJsxTags('', 'Badge.tsx') 94 | expect(code).toBe('') 95 | }) 96 | 97 | it('async', () => { 98 | const code = `export default async function AsyncComponent() { 99 | return

Hello

100 | }` 101 | const result = transformJsxTags(code, 'AsyncComponent.tsx') 102 | expect(result).toBe( 103 | `import { HonoXIsland } from "honox/vite/components"; 104 | const AsyncComponentOriginal = async function () { 105 | return

Hello

; 106 | }; 107 | const WrappedAsyncComponent = function (props) { 108 | return import.meta.env.SSR ? : ; 109 | }; 110 | export default WrappedAsyncComponent;` 111 | ) 112 | }) 113 | 114 | it('unnamed', () => { 115 | const code = `export default async function() { 116 | return

Hello

117 | }` 118 | const result = transformJsxTags(code, 'UnnamedComponent.tsx') 119 | expect(result).toBe( 120 | `import { HonoXIsland } from "honox/vite/components"; 121 | const __HonoIsladComponent__Original = async function () { 122 | return

Hello

; 123 | }; 124 | const Wrapped__HonoIsladComponent__ = function (props) { 125 | return import.meta.env.SSR ? : <__HonoIsladComponent__Original {...props}>; 126 | }; 127 | export default Wrapped__HonoIsladComponent__;` 128 | ) 129 | }) 130 | 131 | it('arrow - block', () => { 132 | const code = `export default () => { 133 | return

Hello

134 | }` 135 | const result = transformJsxTags(code, 'UnnamedComponent.tsx') 136 | expect(result).toBe( 137 | `import { HonoXIsland } from "honox/vite/components"; 138 | const __HonoIsladComponent__Original = () => { 139 | return

Hello

; 140 | }; 141 | const Wrapped__HonoIsladComponent__ = function (props) { 142 | return import.meta.env.SSR ? : <__HonoIsladComponent__Original {...props}>; 143 | }; 144 | export default Wrapped__HonoIsladComponent__;` 145 | ) 146 | }) 147 | 148 | it('arrow - expression', () => { 149 | const code = 'export default () =>

Hello

' 150 | const result = transformJsxTags(code, 'UnnamedComponent.tsx') 151 | expect(result).toBe( 152 | `import { HonoXIsland } from "honox/vite/components"; 153 | const __HonoIsladComponent__Original = () =>

Hello

; 154 | const Wrapped__HonoIsladComponent__ = function (props) { 155 | return import.meta.env.SSR ? : <__HonoIsladComponent__Original {...props}>; 156 | }; 157 | export default Wrapped__HonoIsladComponent__;` 158 | ) 159 | }) 160 | 161 | it('export via variable', () => { 162 | const code = 'export default ExportViaVariable' 163 | const result = transformJsxTags(code, 'ExportViaVariable.tsx') 164 | expect(result).toBe( 165 | `import { HonoXIsland } from "honox/vite/components"; 166 | const WrappedExportViaVariable = function (props) { 167 | return import.meta.env.SSR ? : ; 168 | }; 169 | export default WrappedExportViaVariable;` 170 | ) 171 | }) 172 | 173 | it('export via specifier', () => { 174 | const code = `const utilityFn = () => {} 175 | const ExportViaVariable = () =>

Hello

176 | export { utilityFn, ExportViaVariable as default }` 177 | const result = transformJsxTags(code, 'ExportViaVariable.tsx') 178 | expect(result).toBe( 179 | `import { HonoXIsland } from "honox/vite/components"; 180 | const utilityFn = () => {}; 181 | const ExportViaVariable = () =>

Hello

; 182 | const WrappedExportViaVariable = function (props) { 183 | return import.meta.env.SSR ? : ; 184 | }; 185 | export { utilityFn, WrappedExportViaVariable as default };` 186 | ) 187 | }) 188 | 189 | describe('component name or not', () => { 190 | const codeTemplate = `export function %s() { 191 | return

Hello

; 192 | }` 193 | test.each([ 194 | 'Badge', // simple name 195 | 'BadgeComponent', // camel case 196 | 'BadgeComponent0', // end with number 197 | 'BadgeComponentA', // end with capital letter 198 | 'B1Badge', // "B1" prefix 199 | ])('Should transform %s as component name', (name) => { 200 | const code = codeTemplate.replace('%s', name) 201 | const result = transformJsxTags(code, `${name}.tsx`) 202 | expect(result).not.toBe(code) 203 | }) 204 | 205 | test.each([ 206 | 'utilityFn', // lower camel case 207 | 'utility_fn', // snake case 208 | 'Utility_Fn', // capital snake case 209 | 'MAX', // all capital (constant) 210 | 'MAX_LENGTH', // all capital with underscore (constant) 211 | 'M', // single capital (constant) 212 | 'M1', // single capital with number (constant) 213 | ])('Should not transform %s as component name', (name) => { 214 | const code = codeTemplate.replace('%s', name) 215 | const result = transformJsxTags(code, `${name}.tsx`) 216 | expect(result).toBe(code) 217 | }) 218 | }) 219 | }) 220 | 221 | describe('options', () => { 222 | describe('reactApiImportSource', () => { 223 | describe('vite/components', async () => { 224 | const honoPattern = /'hono\/jsx'/ 225 | const reactPattern = /'react'/ 226 | const setup = async () => { 227 | // get full path of honox-island.tsx 228 | const component = path 229 | .resolve(__dirname, '../vite/components/honox-island.tsx') 230 | // replace backslashes for Windows 231 | .replace(/\\/g, '/') 232 | const componentContent = await fs.readFile(component, 'utf8') 233 | return { component, componentContent } 234 | } 235 | 236 | test.each([ 237 | { 238 | name: 'default with both config files (prefers deno.json over tsconfig.json)', 239 | configFiles: { 240 | 'deno.json': { jsxImportSource: 'react' }, 241 | 'tsconfig.json': { jsxImportSource: 'hono/jsx' }, 242 | }, 243 | config: {}, 244 | expect: { hono: false, react: true }, 245 | }, 246 | { 247 | name: 'default with both config files (prefers deno.json over deno.jsonc)', 248 | configFiles: { 249 | 'deno.json': { jsxImportSource: 'react' }, 250 | 'deno.jsonc': { jsxImportSource: 'hono/jsx' }, 251 | }, 252 | config: {}, 253 | expect: { hono: false, react: true }, 254 | }, 255 | { 256 | name: 'default with both config files (prefers deno.jsonc over tsconfig.json)', 257 | configFiles: { 258 | 'deno.jsonc': { jsxImportSource: 'react' }, 259 | 'tsconfig.json': { jsxImportSource: 'hono/jsx' }, 260 | }, 261 | config: {}, 262 | expect: { hono: false, react: true }, 263 | }, 264 | { 265 | name: 'default with only deno.json', 266 | configFiles: { 267 | 'deno.json': { jsxImportSource: 'react' }, 268 | }, 269 | config: {}, 270 | expect: { hono: false, react: true }, 271 | }, 272 | { 273 | name: 'default with only deno.jsonc', 274 | configFiles: { 275 | 'deno.jsonc': { jsxImportSource: 'react' }, 276 | }, 277 | config: {}, 278 | expect: { hono: false, react: true }, 279 | }, 280 | { 281 | name: 'default with only tsconfig.json', 282 | configFiles: { 283 | 'tsconfig.json': { jsxImportSource: 'react' }, 284 | }, 285 | config: {}, 286 | expect: { hono: false, react: true }, 287 | }, 288 | { 289 | name: 'explicit react config overrides all', 290 | configFiles: { 291 | 'deno.json': { jsxImportSource: 'hono/jsx' }, 292 | 'deno.jsonc': { jsxImportSource: 'hono/jsx' }, 293 | 'tsconfig.json': { jsxImportSource: 'hono/jsx' }, 294 | }, 295 | config: { reactApiImportSource: 'react' }, 296 | expect: { hono: false, react: true }, 297 | }, 298 | ])('should handle $name', async (testCase) => { 299 | const { component, componentContent } = await setup() 300 | 301 | vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { 302 | if (filePath.toString().includes('honox-island.tsx')) { 303 | return componentContent 304 | } 305 | for (const [fileName, config] of Object.entries(testCase.configFiles)) { 306 | if (filePath.toString().includes(fileName)) { 307 | return JSON.stringify({ compilerOptions: config }) 308 | } 309 | throw new Error('Config file does not exist') 310 | } 311 | return '' 312 | }) 313 | 314 | const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 315 | 316 | const plugin = islandComponents(testCase.config) 317 | await (plugin.configResolved as Function)({ root: 'root' }) 318 | const res = await (plugin.load as Function)(component) 319 | 320 | expect(honoPattern.test(res.code)).toBe(testCase.expect.hono) 321 | expect(reactPattern.test(res.code)).toBe(testCase.expect.react) 322 | 323 | if (Object.keys(testCase.configFiles).length === 0) { 324 | expect(consoleWarnSpy).toHaveBeenCalledWith( 325 | 'Cannot find neither tsconfig.json nor deno.json', 326 | expect.any(Error), 327 | expect.any(Error) 328 | ) 329 | } 330 | }) 331 | }) 332 | 333 | describe('server/components', async () => { 334 | beforeEach(() => { 335 | vi.restoreAllMocks() 336 | }) 337 | 338 | const tmpdir = os.tmpdir() 339 | 340 | // has-islands.tsx under src/server/components does not contain 'hono/jsx' 341 | // 'hono/jsx' is injected by `npm run build` 342 | // so we need to create a file with 'hono/jsx' manually for testing 343 | const component = path 344 | .resolve(tmpdir, 'honox/dist/server/components/has-islands.js') 345 | // replace backslashes for Windows 346 | .replace(/\\/g, '/') 347 | await fs.mkdir(path.dirname(component), { recursive: true }) 348 | // prettier-ignore 349 | await fs.writeFile(component, 'import { jsx } from \'hono/jsx/jsx-runtime\'') 350 | 351 | // prettier-ignore 352 | it('use \'hono/jsx\' by default', async () => { 353 | const plugin = islandComponents() 354 | await (plugin.configResolved as Function)({ root: 'root' }) 355 | const res = await (plugin.load as Function)(component) 356 | expect(res.code).toMatch(/'hono\/jsx\/jsx-runtime'/) 357 | expect(res.code).not.toMatch(/'react\/jsx-runtime'/) 358 | }) 359 | 360 | // prettier-ignore 361 | it('enable to specify \'react\'', async () => { 362 | const plugin = islandComponents({ 363 | reactApiImportSource: 'react', 364 | }) 365 | await (plugin.configResolved as Function)({ root: 'root' }) 366 | const res = await (plugin.load as Function)(component) 367 | expect(res.code).not.toMatch(/'hono\/jsx\/jsx-runtime'/) 368 | expect(res.code).toMatch(/'react\/jsx-runtime'/) 369 | }) 370 | }) 371 | }) 372 | }) 373 | -------------------------------------------------------------------------------- /src/vite/island-components.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error don't use types 2 | import _generate from '@babel/generator' 3 | import { parse } from '@babel/parser' 4 | // @ts-expect-error don't use types 5 | import _traverse from '@babel/traverse' 6 | import { 7 | blockStatement, 8 | conditionalExpression, 9 | exportDefaultDeclaration, 10 | exportNamedDeclaration, 11 | exportSpecifier, 12 | functionExpression, 13 | identifier, 14 | importDeclaration, 15 | importSpecifier, 16 | jsxAttribute, 17 | jsxClosingElement, 18 | jsxElement, 19 | jsxExpressionContainer, 20 | jsxIdentifier, 21 | jsxOpeningElement, 22 | jsxSpreadAttribute, 23 | memberExpression, 24 | returnStatement, 25 | stringLiteral, 26 | variableDeclaration, 27 | variableDeclarator, 28 | } from '@babel/types' 29 | import { parse as parseJsonc } from 'jsonc-parser' 30 | import type { Plugin } from 'vite' 31 | import fs from 'fs/promises' 32 | import path from 'path' 33 | import { isComponentName, matchIslandComponentId } from './utils/path.js' 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-ignore 36 | const generate = (_generate.default as typeof _generate) ?? _generate 37 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 38 | // @ts-ignore 39 | const traverse = (_traverse.default as typeof _traverse) ?? _traverse 40 | 41 | function addSSRCheck(funcName: string, componentName: string, componentExport?: string) { 42 | const isSSR = memberExpression( 43 | memberExpression(identifier('import'), identifier('meta')), 44 | identifier('env.SSR') 45 | ) 46 | 47 | const props = [ 48 | jsxAttribute(jsxIdentifier('componentName'), stringLiteral(componentName)), 49 | jsxAttribute(jsxIdentifier('Component'), jsxExpressionContainer(identifier(funcName))), 50 | jsxAttribute(jsxIdentifier('props'), jsxExpressionContainer(identifier('props'))), 51 | ] 52 | if (componentExport && componentExport !== 'default') { 53 | props.push(jsxAttribute(jsxIdentifier('componentExport'), stringLiteral(componentExport))) 54 | } 55 | 56 | const ssrElement = jsxElement( 57 | jsxOpeningElement(jsxIdentifier('HonoXIsland'), props, true), 58 | null, 59 | [] 60 | ) 61 | 62 | const clientElement = jsxElement( 63 | jsxOpeningElement(jsxIdentifier(funcName), [jsxSpreadAttribute(identifier('props'))], false), 64 | jsxClosingElement(jsxIdentifier(funcName)), 65 | [] 66 | ) 67 | 68 | const returnStmt = returnStatement(conditionalExpression(isSSR, ssrElement, clientElement)) 69 | return functionExpression(null, [identifier('props')], blockStatement([returnStmt])) 70 | } 71 | 72 | export const transformJsxTags = (contents: string, componentName: string) => { 73 | const ast = parse(contents, { 74 | sourceType: 'module', 75 | plugins: ['typescript', 'jsx'], 76 | }) 77 | 78 | if (ast) { 79 | let isTransformed = false 80 | 81 | traverse(ast, { 82 | // @ts-expect-error path is not typed 83 | ExportNamedDeclaration(path) { 84 | if (path.node.declaration?.type === 'FunctionDeclaration') { 85 | // transform `export function NamedFunction() {}` 86 | const name = path.node.declaration.id?.name 87 | if (name && isComponentName(name)) { 88 | path.insertBefore(path.node.declaration) 89 | path.replaceWith( 90 | exportNamedDeclaration(null, [exportSpecifier(identifier(name), identifier(name))]) 91 | ) 92 | } 93 | return 94 | } 95 | 96 | if (path.node.declaration?.type === 'VariableDeclaration') { 97 | // transform `export const NamedFunction = () => {}` 98 | const kind = path.node.declaration.kind 99 | for (const declaration of path.node.declaration.declarations) { 100 | if (declaration.id.type === 'Identifier') { 101 | const name = declaration.id.name 102 | if (!isComponentName(name)) { 103 | continue 104 | } 105 | path.insertBefore(variableDeclaration(kind, [declaration])) 106 | path.insertBefore( 107 | exportNamedDeclaration(null, [exportSpecifier(identifier(name), identifier(name))]) 108 | ) 109 | path.remove() 110 | } 111 | } 112 | return 113 | } 114 | 115 | for (const specifier of path.node.specifiers) { 116 | if (specifier.type !== 'ExportSpecifier') { 117 | continue 118 | } 119 | const exportAs = 120 | specifier.exported.type === 'StringLiteral' 121 | ? specifier.exported.value 122 | : specifier.exported.name 123 | if (exportAs !== 'default' && !isComponentName(exportAs)) { 124 | continue 125 | } 126 | isTransformed = true 127 | 128 | const wrappedFunction = addSSRCheck(specifier.local.name, componentName, exportAs) 129 | const wrappedFunctionId = identifier('Wrapped' + specifier.local.name) 130 | path.insertBefore( 131 | variableDeclaration('const', [variableDeclarator(wrappedFunctionId, wrappedFunction)]) 132 | ) 133 | 134 | specifier.local.name = wrappedFunctionId.name 135 | } 136 | }, 137 | // @ts-expect-error path is not typed 138 | ExportDefaultDeclaration(path) { 139 | const declarationType = path.node.declaration.type 140 | if ( 141 | declarationType === 'FunctionDeclaration' || 142 | declarationType === 'FunctionExpression' || 143 | declarationType === 'ArrowFunctionExpression' || 144 | declarationType === 'Identifier' 145 | ) { 146 | isTransformed = true 147 | 148 | const functionName = 149 | (declarationType === 'Identifier' 150 | ? path.node.declaration.name 151 | : (declarationType === 'FunctionDeclaration' || 152 | declarationType === 'FunctionExpression') && 153 | path.node.declaration.id?.name) || '__HonoIsladComponent__' 154 | 155 | let originalFunctionId 156 | if (declarationType === 'Identifier') { 157 | originalFunctionId = path.node.declaration 158 | } else { 159 | originalFunctionId = identifier(functionName + 'Original') 160 | 161 | const originalFunction = 162 | path.node.declaration.type === 'FunctionExpression' || 163 | path.node.declaration.type === 'ArrowFunctionExpression' 164 | ? path.node.declaration 165 | : functionExpression( 166 | null, 167 | path.node.declaration.params, 168 | path.node.declaration.body, 169 | undefined, 170 | path.node.declaration.async 171 | ) 172 | 173 | path.insertBefore( 174 | variableDeclaration('const', [ 175 | variableDeclarator(originalFunctionId, originalFunction), 176 | ]) 177 | ) 178 | } 179 | 180 | const wrappedFunction = addSSRCheck(originalFunctionId.name, componentName) 181 | const wrappedFunctionId = identifier('Wrapped' + functionName) 182 | path.replaceWith( 183 | variableDeclaration('const', [variableDeclarator(wrappedFunctionId, wrappedFunction)]) 184 | ) 185 | ast.program.body.push(exportDefaultDeclaration(wrappedFunctionId)) 186 | } 187 | }, 188 | }) 189 | 190 | if (isTransformed) { 191 | ast.program.body.unshift( 192 | importDeclaration( 193 | [importSpecifier(identifier('HonoXIsland'), identifier('HonoXIsland'))], 194 | stringLiteral('honox/vite/components') 195 | ) 196 | ) 197 | } 198 | 199 | const { code } = generate(ast) 200 | return code 201 | } 202 | } 203 | 204 | type IsIsland = (id: string) => boolean 205 | export type IslandComponentsOptions = { 206 | /** 207 | * @deprecated 208 | */ 209 | isIsland?: IsIsland 210 | islandDir?: string 211 | reactApiImportSource?: string 212 | } 213 | 214 | export function islandComponents(options?: IslandComponentsOptions): Plugin { 215 | let root = '' 216 | let reactApiImportSource = options?.reactApiImportSource 217 | const islandDir = options?.islandDir ?? '/app/islands' 218 | return { 219 | name: 'transform-island-components', 220 | configResolved: async (config) => { 221 | root = config.root 222 | 223 | if (!reactApiImportSource) { 224 | const tsConfigFiles = ['deno.json', 'deno.jsonc', 'tsconfig.json'] 225 | let tsConfigRaw: string | undefined 226 | for (const tsConfigFile of tsConfigFiles) { 227 | try { 228 | const tsConfigPath = path.resolve(process.cwd(), tsConfigFile) 229 | tsConfigRaw = await fs.readFile(tsConfigPath, 'utf8') 230 | break 231 | } catch {} 232 | } 233 | if (!tsConfigRaw) { 234 | console.warn('Cannot find tsconfig.json or deno.json(c)') 235 | return 236 | } 237 | const tsConfig = parseJsonc(tsConfigRaw) 238 | 239 | reactApiImportSource = tsConfig?.compilerOptions?.jsxImportSource 240 | if (reactApiImportSource === 'hono/jsx/dom') { 241 | reactApiImportSource = 'hono/jsx' // we should use hono/jsx instead of hono/jsx/dom 242 | } 243 | } 244 | }, 245 | 246 | async load(id) { 247 | if (/\/honox\/.*?\/(?:server|vite)\/components\//.test(id)) { 248 | if (!reactApiImportSource) { 249 | return 250 | } 251 | const contents = await fs.readFile(id, 'utf-8') 252 | return { 253 | code: contents.replaceAll('hono/jsx', reactApiImportSource), 254 | map: null, 255 | } 256 | } 257 | 258 | const rootPath = '/' + path.relative(root, id).replace(/\\/g, '/') 259 | const match = matchIslandComponentId(rootPath, islandDir) 260 | if (match) { 261 | const componentName = match[0] 262 | const contents = await fs.readFile(id, 'utf-8') 263 | const code = transformJsxTags(contents, componentName) 264 | if (code) { 265 | return { 266 | code, 267 | map: null, 268 | } 269 | } 270 | } 271 | }, 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/vite/restart-on-add-unlink.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite' 2 | 3 | export function restartOnAddUnlink(): Plugin { 4 | return { 5 | name: 'restart-on-add-unlink', 6 | configureServer(server) { 7 | server.watcher.add('/app/**') 8 | server.watcher.on('add', async () => { 9 | await server.restart() 10 | }) 11 | server.watcher.on('unlink', async () => { 12 | await server.restart() 13 | }) 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/vite/utils/path.test.ts: -------------------------------------------------------------------------------- 1 | import { matchIslandComponentId } from './path' 2 | 3 | describe('matchIslandComponentId', () => { 4 | describe('match', () => { 5 | const paths = [ 6 | '/islands/counter.tsx', 7 | '/islands/directory/counter.tsx', 8 | '/routes/$counter.tsx', 9 | '/routes/directory/$counter.tsx', 10 | '/routes/_counter.island.tsx', 11 | '/routes/directory/_counter.island.tsx', 12 | '/$counter.tsx', 13 | '/directory/$counter.tsx', 14 | '/_counter.island.tsx', 15 | '/directory/_counter.island.tsx', 16 | ] 17 | 18 | paths.forEach((path) => { 19 | it(`Should match ${path}`, () => { 20 | const match = matchIslandComponentId(path) 21 | expect(match).not.toBeNull() 22 | expect(match![0]).toBe(path) 23 | }) 24 | }) 25 | }) 26 | 27 | describe('not match', () => { 28 | const paths = [ 29 | '/routes/directory/component.tsx', 30 | '/routes/directory/foo$component.tsx', 31 | '/routes/directory/foo_component.island.tsx', 32 | '/routes/directory/component.island.tsx', 33 | '/directory/islands/component.tsx', 34 | ] 35 | 36 | paths.forEach((path) => { 37 | it(`Should not match ${path}`, () => { 38 | const match = matchIslandComponentId(path) 39 | expect(match).toBeNull() 40 | }) 41 | }) 42 | }) 43 | 44 | describe('not match - with `islandDir`', () => { 45 | const paths = ['/islands/component.tsx'] 46 | 47 | paths.forEach((path) => { 48 | it(`Should not match ${path}`, () => { 49 | const match = matchIslandComponentId(path, '/directory/islands') 50 | expect(match).toBeNull() 51 | }) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/vite/utils/path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the name is a valid component name 3 | * 4 | * @param name - The name to check 5 | * @returns true if the name is a valid component name 6 | * @example 7 | * isComponentName('Badge') // true 8 | * isComponentName('BadgeComponent') // true 9 | * isComponentName('badge') // false 10 | * isComponentName('MIN') // false 11 | * isComponentName('Badge_Component') // false 12 | */ 13 | export function isComponentName(name: string) { 14 | return /^[A-Z][A-Z0-9]*[a-z][A-Za-z0-9]*$/.test(name) 15 | } 16 | 17 | /** 18 | * Matches when id is the filename of Island component 19 | * 20 | * @param id - The id to match 21 | * @returns The result object if id is matched or null 22 | */ 23 | export function matchIslandComponentId(id: string, islandDir: string = '/islands') { 24 | const regExp = new RegExp( 25 | `^${islandDir}\/.+?\.tsx$|.*\/(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\\\$[a-zA-Z0-9-]+\.tsx$)` 26 | ) 27 | return id.match(regExp) 28 | } 29 | -------------------------------------------------------------------------------- /test-e2e/e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test('test counter', async ({ page }) => { 4 | await page.goto('/interaction') 5 | await page.waitForSelector('body[data-client-loaded]') 6 | 7 | const container = page.locator('id=first') 8 | await container.getByText('Count: 5').click() 9 | await container.locator('button').click() 10 | await container.getByText('Count: 6').click() 11 | }) 12 | 13 | test('test named export', async ({ page }) => { 14 | await page.goto('/interaction') 15 | await page.waitForSelector('body[data-client-loaded]') 16 | 17 | const container = page.locator('id=named') 18 | await container.getByText('Count: 30').click() 19 | await container.locator('button').click() 20 | await container.getByText('Count: 31').click() 21 | }) 22 | 23 | test('test counter - island in the same directory', async ({ page }) => { 24 | await page.goto('/directory') 25 | await page.waitForSelector('body[data-client-loaded]') 26 | 27 | await page.getByText('UnderScoreCount: 5').click() 28 | await page.getByRole('button', { name: 'UnderScore Increment' }).click({ 29 | clickCount: 1, 30 | }) 31 | await page.getByText('UnderScoreCount: 6').click() 32 | 33 | await page.getByText('DollarCount: 5').click() 34 | await page.getByRole('button', { name: 'Dollar Increment' }).click({ 35 | clickCount: 1, 36 | }) 37 | await page.getByText('DollarCount: 6').click() 38 | }) 39 | 40 | test('test counter - island in anywhere', async ({ page }) => { 41 | await page.goto('/interaction/anywhere') 42 | await page.waitForSelector('body[data-client-loaded]') 43 | 44 | await page.getByText('Count: 5').click() 45 | await page.getByRole('button', { name: 'Increment' }).click({ 46 | clickCount: 1, 47 | }) 48 | await page.getByText('Count: 6').click() 49 | }) 50 | 51 | test('children - sync', async ({ page }) => { 52 | await page.goto('/interaction/children') 53 | await page.waitForSelector('body[data-client-loaded]') 54 | 55 | const container = page.locator('id=sync') 56 | await container.locator('button').click() 57 | await container.getByText('Count: 1').click() 58 | const div = await container.locator('div') 59 | expect(await div.innerHTML()).toBe( 60 | '

Sync

Sync child' 61 | ) 62 | }) 63 | 64 | test('children - async', async ({ page }) => { 65 | await page.goto('/interaction/children') 66 | await page.waitForSelector('body[data-client-loaded]') 67 | 68 | const container = page.locator('id=async') 69 | await container.locator('button').click() 70 | await container.getByText('Count: 3').click() 71 | await container.getByText('Async child').click() 72 | }) 73 | 74 | test('children - nest', async ({ page }) => { 75 | await page.goto('/interaction/children') 76 | await page.waitForSelector('body[data-client-loaded]') 77 | 78 | const container = page.locator('id=nest') 79 | await container.locator('button').first().click() 80 | await container.getByText('Count: 11').click() 81 | 82 | const childContainer = page.locator('id=child') 83 | await childContainer.locator('button').click() 84 | await childContainer.getByText('Count: 13').click() 85 | }) 86 | 87 | test('suspense', async ({ page }) => { 88 | await page.goto('/interaction/suspense', { waitUntil: 'domcontentloaded' }) 89 | await page.waitForSelector('body[data-client-loaded]') 90 | const container = page.locator('id=suspense') 91 | await container.locator('button').click() 92 | await container.getByText('Count: 5').click() 93 | await container.getByText('Suspense child').click() 94 | }) 95 | 96 | test('suspense never resolved', async ({ page }) => { 97 | await page.goto('/interaction/suspense-never', { timeout: 1 }).catch(() => {}) // proceed test as soon as possible 98 | await page.waitForSelector('body[data-client-loaded]') 99 | 100 | const container = page.locator('id=suspense-never') 101 | await container.locator('button').click() 102 | await container.getByText('Count: 7').click() 103 | await container.getByText('Loading...').click() 104 | }) 105 | 106 | test('error-boundary', async ({ page }) => { 107 | await page.goto('/interaction/error-boundary', { waitUntil: 'domcontentloaded' }) 108 | await page.waitForSelector('body[data-client-loaded]') 109 | const container = page.locator('id=error-boundary-success') 110 | await container.locator('button').click() 111 | await container.getByText('Count: 3').click() 112 | await container.getByText('Suspense child').click() 113 | }) 114 | 115 | test('error-boundary failure', async ({ page }) => { 116 | await page.goto('/interaction/error-boundary', { waitUntil: 'domcontentloaded' }) 117 | await page.waitForSelector('body[data-client-loaded]') 118 | const container = page.locator('id=error-boundary-failure') 119 | await container.locator('button').click() 120 | await container.getByText('Count: 5').click() 121 | const div = await container.locator('div') 122 | expect(await div.innerHTML()).toBe('Something went wrong') 123 | }) 124 | 125 | test('server-side suspense contains island components', async ({ page }) => { 126 | await page.goto('/interaction/suspense-islands', { timeout: 1 }).catch(() => {}) // proceed test as soon as possible 127 | await page.waitForSelector('body[data-client-loaded]') 128 | 129 | const container = page.locator('id=suspense-islands') 130 | await container.locator('button').click() 131 | await container.getByText('Count: 7').click() 132 | await container.getByText('Suspense Islands').click() 133 | }) 134 | 135 | test('/app/nested', async ({ page }) => { 136 | await page.goto('/app/nested') 137 | const contentH1 = await page.textContent('h1') 138 | expect(contentH1).toBe('Nested') 139 | }) 140 | -------------------------------------------------------------------------------- /test-e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | export default defineConfig({ 4 | fullyParallel: true, 5 | forbidOnly: !!process.env.CI, 6 | retries: process.env.CI ? 2 : 0, 7 | workers: process.env.CI ? 1 : undefined, 8 | use: { 9 | baseURL: 'http://localhost:6173', 10 | }, 11 | projects: [ 12 | { 13 | name: 'chromium', 14 | use: { ...devices['Desktop Chrome'] }, 15 | timeout: 5000, 16 | retries: 2, 17 | }, 18 | ], 19 | webServer: { 20 | command: 'cd ../mocks && vite --port 6173 -c ./vite.config.ts', 21 | port: 6173, 22 | reuseExistingServer: !process.env.CI, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /test-integration/api.test.ts: -------------------------------------------------------------------------------- 1 | import { poweredBy } from 'hono/powered-by' 2 | import { createApp } from '../src/server' 3 | 4 | describe('Basic', () => { 5 | const ROUTES = import.meta.glob('../mocks/api/routes/**/[a-z[-][a-z[_-]*.ts', { 6 | eager: true, 7 | }) 8 | 9 | const app = createApp({ 10 | root: '../mocks/api/routes', 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | ROUTES: ROUTES as any, 13 | init: (app) => { 14 | app.use('*', poweredBy()) 15 | }, 16 | }) 17 | 18 | it('Should have correct routes', () => { 19 | const routes: { path: string; method: string }[] = [ 20 | { 21 | path: '/about/:name', 22 | method: 'GET', 23 | }, 24 | { 25 | path: '/about/:name/address', 26 | method: 'GET', 27 | }, 28 | { 29 | path: '/middleware/*', 30 | method: 'ALL', 31 | }, 32 | { 33 | path: '/middleware/foo', 34 | method: 'GET', 35 | }, 36 | { path: '/', method: 'GET' }, 37 | { path: '/foo', method: 'GET' }, 38 | ] 39 | 40 | expect(app.routes).toHaveLength(routes.length * 2) 41 | expect(app.routes).toEqual( 42 | expect.arrayContaining( 43 | routes.map(({ path, method }) => { 44 | return { 45 | path, 46 | method, 47 | handler: expect.any(Function), 48 | } 49 | }) 50 | ) 51 | ) 52 | }) 53 | 54 | it('Should return 200 response - / with a Powered By header', async () => { 55 | const res = await app.request('/') 56 | expect(res.status).toBe(200) 57 | expect(await res.json()).toEqual({ 58 | path: '/', 59 | }) 60 | expect(res.headers.get('x-powered-by')).toBe('Hono') 61 | }) 62 | 63 | it('Should return 200 response - /foo', async () => { 64 | const res = await app.request('/foo') 65 | expect(res.status).toBe(200) 66 | expect(await res.json()).toEqual({ 67 | path: '/foo', 68 | }) 69 | }) 70 | 71 | it('Should return 404 response - /bar', async () => { 72 | const res = await app.request('/bar') 73 | expect(res.status).toBe(404) 74 | }) 75 | 76 | it('Should return 200 response /about/me', async () => { 77 | const res = await app.request('/about/me') 78 | expect(res.status).toBe(200) 79 | expect(await res.json()).toEqual({ 80 | path: '/about/me', 81 | }) 82 | }) 83 | 84 | it('Should return 200 response /about/me/address', async () => { 85 | const res = await app.request('/about/me/address') 86 | expect(res.status).toBe(200) 87 | expect(await res.json()).toEqual({ 88 | path: '/about/me/address', 89 | }) 90 | }) 91 | 92 | it('Should return 200 with header values /middleware', async () => { 93 | const res = await app.request('/middleware') 94 | expect(res.status).toBe(200) 95 | expect(res.headers.get('x-powered-by')).toBe('Hono') 96 | expect(await res.json()).toEqual({ 97 | path: '/middleware', 98 | }) 99 | }) 100 | 101 | it('Should return 200 with header values /middleware/foo', async () => { 102 | const res = await app.request('/middleware/foo') 103 | expect(res.status).toBe(200) 104 | expect(res.headers.get('x-powered-by')).toBe('Hono') 105 | expect(await res.json()).toEqual({ 106 | path: '/middleware/foo', 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test-integration/apps.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { poweredBy } from 'hono/powered-by' 3 | import { createApp } from '../src/server' 4 | 5 | describe('Basic', () => { 6 | const ROUTES = import.meta.glob('../mocks/app/routes/**/[a-z[-][a-z[_-]*.(tsx|ts|mdx)', { 7 | eager: true, 8 | }) 9 | const NOT_FOUND = import.meta.glob('../mocks/app/routes/**/_404.(ts|tsx)', { 10 | eager: true, 11 | }) 12 | 13 | const app = createApp({ 14 | root: '../mocks/app/routes', 15 | ROUTES: ROUTES as any, 16 | NOT_FOUND: NOT_FOUND as any, 17 | init: (app) => { 18 | app.use('*', poweredBy()) 19 | }, 20 | }) 21 | 22 | it('Should have correct routes', () => { 23 | const routes: { path: string; method: string }[] = [ 24 | { 25 | path: '/*', 26 | method: 'ALL', 27 | }, 28 | { 29 | path: '/about/:name/address', 30 | method: 'GET', 31 | }, 32 | { 33 | path: '/about/:name', 34 | method: 'GET', 35 | }, 36 | { 37 | path: '/about/:name', 38 | method: 'POST', 39 | }, 40 | { 41 | path: '/non-interactive', 42 | method: 'GET', 43 | }, 44 | { 45 | path: '/interaction', 46 | method: 'GET', 47 | }, 48 | { 49 | path: '/interaction/anywhere', 50 | method: 'GET', 51 | }, 52 | { 53 | path: '/interaction/children', 54 | method: 'GET', 55 | }, 56 | { 57 | path: '/interaction/error-boundary', 58 | method: 'GET', 59 | }, 60 | { 61 | path: '/interaction/suspense-never', 62 | method: 'GET', 63 | }, 64 | { 65 | path: '/interaction/suspense', 66 | method: 'GET', 67 | }, 68 | { 69 | path: '/interaction/suspense-islands', 70 | method: 'GET', 71 | }, 72 | { 73 | path: '/interaction/nested', 74 | method: 'GET', 75 | }, 76 | { 77 | path: '/directory', 78 | method: 'GET', 79 | }, 80 | { 81 | path: '/directory/throw_error', 82 | method: 'GET', 83 | }, 84 | { 85 | path: '/directory/sub/throw_error', 86 | method: 'GET', 87 | }, 88 | { 89 | path: '/fc', 90 | method: 'GET', 91 | }, 92 | { path: '/api', method: 'POST' }, 93 | { path: '/api', method: 'GET' }, 94 | { path: '/', method: 'GET' }, 95 | { 96 | path: '/post', 97 | method: 'GET', 98 | }, 99 | { 100 | path: '/throw_error', 101 | method: 'GET', 102 | }, 103 | { 104 | path: '/app/nested', 105 | method: 'GET', 106 | }, 107 | ] 108 | expect(app.routes).toHaveLength(52) 109 | expect(app.routes).toEqual( 110 | expect.arrayContaining( 111 | routes.map(({ path, method }) => { 112 | return { 113 | path, 114 | method, 115 | handler: expect.any(Function), 116 | } 117 | }) 118 | ) 119 | ) 120 | }) 121 | 122 | it('Should return 200 response - / with a Powered By header', async () => { 123 | const res = await app.request('/') 124 | expect(res.status).toBe(200) 125 | expect(await res.text()).toBe('

Hello

') 126 | expect(res.headers.get('x-powered-by'), 'Hono') 127 | }) 128 | 129 | it('Should return 404 response - /foo', async () => { 130 | const res = await app.request('/foo') 131 | expect(res.status).toBe(404) 132 | }) 133 | 134 | it('Should return custom 404 response - /not-found', async () => { 135 | const res = await app.request('/not-found') 136 | expect(res.status).toBe(404) 137 | expect(await res.text()).toBe('

Not Found

') 138 | }) 139 | 140 | it('Should return 200 response - /about/me', async () => { 141 | const res = await app.request('/about/me') 142 | expect(res.status).toBe(200) 143 | // hono/jsx escape a single quote to ' 144 | expect(await res.text()).toBe('

It's me

My name is me') 145 | }) 146 | 147 | it('Should return 200 response - POST /about/me', async () => { 148 | const res = await app.request('/about/me', { 149 | method: 'POST', 150 | }) 151 | expect(res.status).toBe(201) 152 | }) 153 | 154 | it('Should return 200 response - GET /fc', async () => { 155 | const res = await app.request('/fc') 156 | expect(res.status).toBe(200) 157 | expect(await res.text()).toBe('

Function from /fc

') 158 | }) 159 | 160 | it('Should not determined as an island component - GET /non-interactive', async () => { 161 | const res = await app.request('/non-interactive') 162 | expect(res.status).toBe(200) 163 | expect(await res.text()).toBe('

Not Island

') 164 | }) 165 | 166 | it('Should render MDX content - /post', async () => { 167 | const res = await app.request('/post') 168 | expect(res.status).toBe(200) 169 | expect(await res.text()).toBe('

Hello MDX

') 170 | }) 171 | 172 | it('Should return 500 response - /throw_error', async () => { 173 | global.console.trace = vi.fn() 174 | const res = await app.request('/throw_error') 175 | expect(res.status).toBe(500) 176 | expect(await res.text()).toBe('Internal Server Error') 177 | }) 178 | }) 179 | 180 | describe('With preserved', () => { 181 | const ROUTES = import.meta.glob('../mocks/app/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 182 | eager: true, 183 | }) 184 | 185 | const RENDERER = import.meta.glob('../mocks/app/routes/**/_renderer.tsx', { 186 | eager: true, 187 | }) 188 | 189 | const NOT_FOUND = import.meta.glob('../mocks/app/routes/_404.tsx', { 190 | eager: true, 191 | }) 192 | 193 | const ERROR = import.meta.glob('../mocks/app/routes/**/_error.tsx', { 194 | eager: true, 195 | }) 196 | 197 | const MIDDLEWARE = import.meta.glob('../mocks/app/routes/**/_middleware.(tsx|ts)', { 198 | eager: true, 199 | }) 200 | 201 | const app = createApp({ 202 | root: '../mocks/app/routes', 203 | ROUTES: ROUTES as any, 204 | RENDERER: RENDERER as any, 205 | NOT_FOUND: NOT_FOUND as any, 206 | ERROR: ERROR as any, 207 | MIDDLEWARE: MIDDLEWARE as any, 208 | }) 209 | 210 | it('Should return 200 response - /', async () => { 211 | const res = await app.request('/') 212 | expect(res.status).toBe(200) 213 | expect(await res.text()).toBe( 214 | 'This is a title

Hello

' 215 | ) 216 | }) 217 | 218 | it('Should return 404 response - /foo', async () => { 219 | const res = await app.request('/foo') 220 | expect(res.status).toBe(404) 221 | expect(await res.text()).toBe( 222 | 'Not Found

Not Found

' 223 | ) 224 | }) 225 | 226 | it('Should return 200 response - /about/me', async () => { 227 | const res = await app.request('/about/me') 228 | expect(res.status).toBe(200) 229 | // hono/jsx escape a single quote to ' 230 | expect(await res.text()).toBe( 231 | 'me

It's me

My name is me' 232 | ) 233 | }) 234 | 235 | it('Should return 200 response - /about/me/address', async () => { 236 | const res = await app.request('/about/me/address') 237 | expect(res.status).toBe(200) 238 | expect(res.headers.get('x-message')).toBe('from middleware') 239 | // hono/jsx escape a single quote to ' 240 | expect(await res.text()).toBe( 241 | 'me's address

About

me's address
' 242 | ) 243 | }) 244 | 245 | it('Should return 200 response - /about/me/hobbies/baseball', async () => { 246 | const res = await app.request('/about/me/hobbies/baseball') 247 | expect(res.status).toBe(200) 248 | expect(res.headers.get('x-message')).toBe('from middleware') 249 | expect(res.headers.get('x-message-nested')).toBe('from nested middleware') 250 | // hono/jsx escape a single quote to ' 251 | expect(await res.text()).toBe( 252 | '

About

me's hobby is baseball

' 253 | ) 254 | }) 255 | 256 | it('Should return 200 response - /interaction', async () => { 257 | const res = await app.request('/interaction') 258 | expect(res.status).toBe(200) 259 | // hono/jsx escape a single quote to ' 260 | expect(await res.text()).toBe( 261 | '

Counter

Count: 5

Counter

Count: 10

Counter

Count: 15

Counter

Count: 30

Counter

Count: 20

Counter

Count: 30

Counter

Count: 25

' 262 | ) 263 | }) 264 | 265 | it('Should return 200 response - /interaction/anywhere', async () => { 266 | const res = await app.request('/interaction/anywhere') 267 | expect(res.status).toBe(200) 268 | expect(await res.text()).toBe( 269 | '

Count: 5

' 270 | ) 271 | }) 272 | 273 | it('Should return 200 response - /interaction/nested', async () => { 274 | const res = await app.request('/interaction/nested') 275 | expect(res.status).toBe(200) 276 | // hono/jsx escape a single quote to ' 277 | expect(await res.text()).toBe( 278 | ` 279 | 280 | 281 | 282 | 283 | 284 | 285 |
286 |

Nested Island Test

287 | 288 |
289 |

Counter

290 |

Count: 0

291 | 292 |
293 |
294 |
295 | 296 | 297 | 298 | `.replace(/\n|\s{2,}/g, '') 299 | ) 300 | }) 301 | 302 | it('Should return 200 response - /directory', async () => { 303 | const res = await app.request('/directory') 304 | expect(res.status).toBe(200) 305 | // hono/jsx escape a single quote to ' 306 | expect(await res.text()).toBe( 307 | '

UnderScoreCount: 5

DollarCount: 5

' 308 | ) 309 | }) 310 | 311 | it('Should return 500 response - /throw_error', async () => { 312 | const res = await app.request('/throw_error') 313 | expect(res.status).toBe(500) 314 | expect(await res.text()).toBe( 315 | 'Internal Server Error

Custom Error Message: Foo

' 316 | ) 317 | }) 318 | 319 | it('Should return 500 response - /directory/throw_error', async () => { 320 | const res = await app.request('/directory/throw_error') 321 | expect(res.status).toBe(500) 322 | expect(await res.text()).toBe( 323 | '

Custom Error in /directory: Foo

' 324 | ) 325 | }) 326 | 327 | it('Should return 500 response - /directory/sub/throw_error', async () => { 328 | const res = await app.request('/directory/sub/throw_error') 329 | expect(res.status).toBe(500) 330 | expect(await res.text()).toBe( 331 | '

Custom Error in /directory: Foo

' 332 | ) 333 | }) 334 | }) 335 | 336 | describe('API', () => { 337 | const ROUES = import.meta.glob('../mocks/app/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 338 | eager: true, 339 | }) 340 | 341 | const app = createApp({ 342 | root: '../mocks/app/routes', 343 | ROUTES: ROUES as any, 344 | }) 345 | 346 | it('Should return 200 response - /api', async () => { 347 | const res = await app.request('/api') 348 | expect(res.status).toBe(200) 349 | expect(res.headers.get('X-Custom')).toBe('Hello') 350 | expect(await res.json()).toEqual({ foo: 'bar' }) 351 | }) 352 | 353 | it('Should return 200 response - POST /api', async () => { 354 | const res = await app.request('/api', { 355 | method: 'POST', 356 | }) 357 | expect(res.status).toBe(201) 358 | expect(await res.json()).toEqual({ 359 | ok: true, 360 | message: 'created', 361 | }) 362 | }) 363 | }) 364 | 365 | describe('Nested Layouts', () => { 366 | const ROUTES = import.meta.glob('../mocks/app-nested/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 367 | eager: true, 368 | }) 369 | 370 | const RENDERER = import.meta.glob('../mocks/app-nested/routes/**/_renderer.tsx', { 371 | eager: true, 372 | }) 373 | 374 | const app = createApp({ 375 | root: '../mocks/app-nested/routes', 376 | ROUTES: ROUTES as any, 377 | RENDERER: RENDERER as any, 378 | }) 379 | 380 | it('Should return 200 response - /nested', async () => { 381 | const res = await app.request('/nested') 382 | expect(res.status).toBe(200) 383 | expect(await res.text()).toBe('

Nested

') 384 | }) 385 | 386 | it('Should return 200 response - /nested/foo', async () => { 387 | const res = await app.request('/nested/foo') 388 | expect(res.status).toBe(200) 389 | expect(await res.text()).toBe('

Foo

') 390 | }) 391 | 392 | it('Should return 200 response - /nested/foo/bar', async () => { 393 | const res = await app.request('/nested/foo/bar') 394 | expect(res.status).toBe(200) 395 | expect(await res.text()).toBe('

Bar

') 396 | }) 397 | 398 | it('Should return 200 response - /nested/foo/bar/baz', async () => { 399 | const res = await app.request('/nested/foo/bar/baz') 400 | expect(res.status).toBe(200) 401 | expect(await res.text()).toBe('

Baz

') 402 | }) 403 | }) 404 | 405 | describe('Nested Middleware', () => { 406 | const ROUTES = import.meta.glob( 407 | '../mocks/app-nested-middleware/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', 408 | { 409 | eager: true, 410 | } 411 | ) 412 | 413 | const MIDDLEWARE = import.meta.glob( 414 | '../mocks/app-nested-middleware/routes/**/_middleware.(tsx|ts)', 415 | { 416 | eager: true, 417 | } 418 | ) 419 | 420 | const app = createApp({ 421 | root: '../mocks/app-nested-middleware/routes', 422 | ROUTES: ROUTES as any, 423 | MIDDLEWARE: MIDDLEWARE as any, 424 | }) 425 | 426 | it('Should have "top" header - /', async () => { 427 | const res = await app.request('/') 428 | expect(res.status).toBe(200) 429 | expect(res.headers.get('top')).toEqual('top') 430 | }) 431 | it('Should have "sub" header - /nested', async () => { 432 | const res = await app.request('/nested') 433 | expect(res.status).toBe(200) 434 | expect(res.headers.get('top')).toEqual('top') 435 | expect(res.headers.get('sub')).toEqual('sub') 436 | }) 437 | it('Should have "foo" header and parent headers - /nested/foo', async () => { 438 | const res = await app.request('/nested/foo') 439 | expect(res.status).toBe(200) 440 | expect(res.headers.get('top')).toEqual('top') 441 | expect(res.headers.get('sub')).toEqual('sub') 442 | expect(res.headers.get('foo')).toEqual('foo') 443 | }) 444 | it('Should have "bar" header and parent headers - /nested/foo/bar', async () => { 445 | const res = await app.request('/nested/foo/bar') 446 | expect(res.status).toBe(200) 447 | expect(res.headers.get('top')).toEqual('top') 448 | expect(res.headers.get('sub')).toEqual('sub') 449 | expect(res.headers.get('foo')).toEqual('foo') 450 | expect(res.headers.get('bar')).toEqual('bar') 451 | }) 452 | it('Should have "baz" header and parent headers - /nested/foo/bar/baz', async () => { 453 | const res = await app.request('/nested/foo/bar/baz') 454 | expect(res.status).toBe(200) 455 | expect(res.headers.get('top')).toEqual('top') 456 | expect(res.headers.get('sub')).toEqual('sub') 457 | expect(res.headers.get('foo')).toEqual('foo') 458 | expect(res.headers.get('bar')).toEqual('bar') 459 | expect(res.headers.get('baz')).toEqual('baz') 460 | }) 461 | it('Should have headers added by parent middleware - /nested/foo/bar/123', async () => { 462 | const res = await app.request('/nested/foo/bar/123') 463 | expect(res.status).toBe(200) 464 | expect(res.headers.get('top')).toEqual('top') 465 | expect(res.headers.get('sub')).toEqual('sub') 466 | expect(res.headers.get('foo')).toEqual('foo') 467 | expect(res.headers.get('bar')).toEqual('bar') 468 | }) 469 | }) 470 | 471 | describe('

Component

' 492 | ) 493 | }) 494 | 495 | it('Should convert the script path correctly - With `app = new Hono()` style', async () => { 496 | const res = await app.request('/classic') 497 | expect(res.status).toBe(200) 498 | expect(await res.text()).toBe( 499 | '

Component

' 500 | ) 501 | }) 502 | 503 | describe('with base path - root relative', () => { 504 | const originalBaseURL = import.meta.env.BASE_URL 505 | 506 | beforeAll(() => { 507 | // this means `base: "/base/path/"` in vite.config.ts 508 | import.meta.env.BASE_URL = '/base/path/' 509 | }) 510 | 511 | afterAll(() => { 512 | import.meta.env.BASE_URL = originalBaseURL 513 | }) 514 | 515 | it('Should convert the script path correctly', async () => { 516 | const res = await app.request('/') 517 | expect(res.status).toBe(200) 518 | expect(await res.text()).toBe( 519 | '

Component

' 520 | ) 521 | }) 522 | }) 523 | 524 | describe('with base path - root relative, without trailing slash', () => { 525 | const originalBaseURL = import.meta.env.BASE_URL 526 | 527 | beforeAll(() => { 528 | // this means `base: "/base/path"` in vite.config.ts 529 | import.meta.env.BASE_URL = '/base/path' 530 | }) 531 | 532 | afterAll(() => { 533 | import.meta.env.BASE_URL = originalBaseURL 534 | }) 535 | 536 | it('Should convert the script path correctly', async () => { 537 | const res = await app.request('/') 538 | expect(res.status).toBe(200) 539 | expect(await res.text()).toBe( 540 | '

Component

' 541 | ) 542 | }) 543 | }) 544 | 545 | describe('with base path - absolute url', () => { 546 | const originalBaseURL = import.meta.env.BASE_URL 547 | 548 | beforeAll(() => { 549 | // this means `base: "https://example.com/base/path/"` in vite.config.ts 550 | import.meta.env.BASE_URL = 'https://example.com/base/path/' 551 | }) 552 | 553 | afterAll(() => { 554 | import.meta.env.BASE_URL = originalBaseURL 555 | }) 556 | 557 | it('Should convert the script path correctly', async () => { 558 | const res = await app.request('/') 559 | expect(res.status).toBe(200) 560 | expect(await res.text()).toBe( 561 | '

Component

' 562 | ) 563 | }) 564 | }) 565 | }) 566 | 567 | describe('With async', () => { 568 | const RENDERER = import.meta.glob('../mocks/app-script/routes/**/_async_renderer.tsx', { 569 | eager: true, 570 | }) 571 | 572 | const app = createApp({ 573 | root: '../mocks/app-script/routes', 574 | ROUTES: ROUTES as any, 575 | RENDERER: RENDERER as any, 576 | }) 577 | 578 | it('Should convert the script path correctly', async () => { 579 | const res = await app.request('/') 580 | expect(res.status).toBe(200) 581 | expect(await res.text()).toBe( 582 | '

Component

' 583 | ) 584 | }) 585 | }) 586 | 587 | describe('With nonce', () => { 588 | const RENDERER = import.meta.glob('../mocks/app-script/routes/**/_nonce_renderer.tsx', { 589 | eager: true, 590 | }) 591 | 592 | const app = createApp({ 593 | root: '../mocks/app-script/routes', 594 | ROUTES: ROUTES as any, 595 | RENDERER: RENDERER as any, 596 | }) 597 | 598 | it('Should convert the script path correctly', async () => { 599 | const res = await app.request('/') 600 | expect(res.status).toBe(200) 601 | expect(await res.text()).toBe( 602 | '

Component

' 603 | ) 604 | }) 605 | }) 606 | }) 607 | 608 | describe(' component', () => { 609 | const ROUTES = import.meta.glob('../mocks/app-link/routes/**/index.tsx', { 610 | eager: true, 611 | }) 612 | 613 | describe('default (rel=stylesheet, absolute path)', () => { 614 | const RENDERER = import.meta.glob('../mocks/app-link/routes/**/_renderer.tsx', { 615 | eager: true, 616 | }) 617 | 618 | const app = createApp({ 619 | root: '../mocks/app-link/routes', 620 | ROUTES: ROUTES as any, 621 | RENDERER: RENDERER as any, 622 | }) 623 | 624 | it('Should convert the link path correctly', async () => { 625 | const res = await app.request('/') 626 | expect(res.status).toBe(200) 627 | expect(await res.text()).toBe( 628 | '
' 629 | ) 630 | }) 631 | 632 | describe('with base path - root relative', () => { 633 | const originalBaseURL = import.meta.env.BASE_URL 634 | 635 | beforeAll(() => { 636 | // this means `base: "/base/path/"` in vite.config.ts 637 | import.meta.env.BASE_URL = '/base/path/' 638 | }) 639 | 640 | afterAll(() => { 641 | import.meta.env.BASE_URL = originalBaseURL 642 | }) 643 | 644 | it('Should convert the link path correctly', async () => { 645 | const res = await app.request('/') 646 | expect(res.status).toBe(200) 647 | expect(await res.text()).toBe( 648 | '
' 649 | ) 650 | }) 651 | }) 652 | 653 | describe('with base path - root relative, without trailing slash', () => { 654 | const originalBaseURL = import.meta.env.BASE_URL 655 | 656 | beforeAll(() => { 657 | // this means `base: "/base/path"` in vite.config.ts 658 | import.meta.env.BASE_URL = '/base/path' 659 | }) 660 | 661 | afterAll(() => { 662 | import.meta.env.BASE_URL = originalBaseURL 663 | }) 664 | 665 | it('Should convert the link path correctly', async () => { 666 | const res = await app.request('/') 667 | expect(res.status).toBe(200) 668 | expect(await res.text()).toBe( 669 | '
' 670 | ) 671 | }) 672 | }) 673 | 674 | describe('with base path - absolute url', () => { 675 | const originalBaseURL = import.meta.env.BASE_URL 676 | 677 | beforeAll(() => { 678 | // this means `base: "https://example.com/base/path/"` in vite.config.ts 679 | import.meta.env.BASE_URL = 'https://example.com/base/path/' 680 | }) 681 | 682 | afterAll(() => { 683 | import.meta.env.BASE_URL = originalBaseURL 684 | }) 685 | 686 | it('Should convert the link path correctly', async () => { 687 | const res = await app.request('/') 688 | expect(res.status).toBe(200) 689 | expect(await res.text()).toBe( 690 | '
' 691 | ) 692 | }) 693 | }) 694 | }) 695 | }) 696 | 697 | describe(' Component with path aliases', () => { 698 | const ROUES = import.meta.glob('../mocks/app-alias/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 699 | eager: true, 700 | }) 701 | const RENDERER = import.meta.glob('../mocks/app-alias/routes/**/_renderer.tsx', { 702 | eager: true, 703 | }) 704 | 705 | const app = createApp({ 706 | root: '../mocks/app-alias/routes', 707 | ROUTES: ROUES as any, 708 | RENDERER: RENDERER as any, 709 | }) 710 | 711 | it('Should return a script tag with tagged HasIslands - /has-islands', async () => { 712 | const res = await app.request('/has-islands') 713 | expect(res.status).toBe(200) 714 | expect(await res.text()).toBe( 715 | '
Counter
' 716 | ) 717 | }) 718 | 719 | it('Should no return a script tag - /has-no-islands', async () => { 720 | const res = await app.request('/has-no-islands') 721 | expect(res.status).toBe(200) 722 | expect(await res.text()).toBe('

No Islands

') 723 | }) 724 | }) 725 | 726 | describe(' Component with path alias with vite-tsconfig-paths', () => { 727 | const ROUES = import.meta.glob( 728 | '../mocks/app-alias-tsconfig-paths/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', 729 | { 730 | eager: true, 731 | } 732 | ) 733 | const RENDERER = import.meta.glob('../mocks/app-alias-tsconfig-paths/routes/**/_renderer.tsx', { 734 | eager: true, 735 | }) 736 | 737 | const app = createApp({ 738 | root: '../mocks/app-alias-tsconfig-paths/routes', 739 | ROUTES: ROUES as any, 740 | RENDERER: RENDERER as any, 741 | }) 742 | 743 | it('Should return a script tag with tagged HasIslands - /has-islands', async () => { 744 | const res = await app.request('/has-islands') 745 | expect(res.status).toBe(200) 746 | expect(await res.text()).toBe( 747 | '
Counter
' 748 | ) 749 | }) 750 | 751 | it('Should no return a script tag - /has-no-islands', async () => { 752 | const res = await app.request('/has-no-islands') 753 | expect(res.status).toBe(200) 754 | expect(await res.text()).toBe('

No Islands

') 755 | }) 756 | }) 757 | 758 | describe('Island Components with Preserved Files', () => { 759 | const ROUTES = import.meta.glob( 760 | '../mocks/app-islands-in-preserved/routes/**/[a-z[-][a-z-_[]*.(tsx|ts|mdx)', 761 | { 762 | eager: true, 763 | } 764 | ) 765 | const RENDERER = import.meta.glob('../mocks/app-islands-in-preserved/routes/**/_renderer.tsx', { 766 | eager: true, 767 | }) 768 | const NOT_FOUND = import.meta.glob('../mocks/app-islands-in-preserved/routes/_404.tsx', { 769 | eager: true, 770 | }) 771 | const ERROR = import.meta.glob('../mocks/app-islands-in-preserved/routes/_error.tsx', { 772 | eager: true, 773 | }) 774 | 775 | const app = createApp({ 776 | root: '../mocks/app-islands-in-preserved/routes', 777 | ROUTES: ROUTES as any, 778 | RENDERER: RENDERER as any, 779 | NOT_FOUND: NOT_FOUND as any, 780 | ERROR: ERROR as any, 781 | }) 782 | 783 | it('Ensures scripts are loaded for island components within preserved files on 404 routes', async () => { 784 | const res = await app.request('/foo') 785 | expect(res.status).toBe(404) 786 | expect(await res.text()).toBe( 787 | 'Not Found

Count: 0

' 788 | ) 789 | }) 790 | 791 | it('Ensures scripts are loaded for island components within preserved files on error routes', async () => { 792 | const res = await app.request('/throw_error') 793 | expect(res.status).toBe(500) 794 | expect(await res.text()).toBe( 795 | 'Internal Server Error

Count: 0

' 796 | ) 797 | }) 798 | 799 | it('Ensures nested components, including MDX content and islands, load scripts correctly', async () => { 800 | const res = await app.request('/nested/post') 801 | expect(res.status).toBe(200) 802 | expect(await res.text()).toBe( 803 | '

Count: 0

Hello MDX

' 804 | ) 805 | }) 806 | }) 807 | 808 | describe('Trailing Slash', () => { 809 | const ROUTES = import.meta.glob('../mocks/app-nested/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 810 | eager: true, 811 | }) 812 | 813 | const app = createApp({ 814 | root: '../mocks/app-nested/routes', 815 | ROUTES: ROUTES as any, 816 | trailingSlash: true, 817 | }) 818 | 819 | const paths = ['/nested', '/nested/foo', '/nested/foo/bar'] 820 | for (const path of paths) { 821 | it(`Should return 404 response - ${path}`, async () => { 822 | const res = await app.request(path) 823 | expect(res.status).toBe(404) 824 | }) 825 | it(`Should return 200 response - ${path}/`, async () => { 826 | const res = await app.request(`${path}/`) 827 | expect(res.status).toBe(200) 828 | }) 829 | } 830 | 831 | it('Should return 200 response - /top', async () => { 832 | const res = await app.request('/top') 833 | expect(res.status).toBe(200) 834 | }) 835 | it('Should return 404 response - /top/', async () => { 836 | const res = await app.request('/top/') 837 | expect(res.status).toBe(404) 838 | }) 839 | }) 840 | 841 | describe('Nested Dynamic Routes', () => { 842 | const ROUTES = import.meta.glob( 843 | '../mocks/app-nested-dynamic-routes/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', 844 | { 845 | eager: true, 846 | } 847 | ) 848 | 849 | const app = createApp({ 850 | root: '../mocks/app-nested-dynamic-routes/routes', 851 | ROUTES: ROUTES as any, 852 | }) 853 | 854 | it('Should return 200 response - /resource', async () => { 855 | const res = await app.request('/resource') 856 | expect(res.status).toBe(200) 857 | expect(await res.text()).toBe('
Resource Home
') 858 | }) 859 | 860 | it('Should return 200 response - /resource/new', async () => { 861 | const res = await app.request('/resource/new') 862 | expect(res.status).toBe(200) 863 | expect(await res.text()).toBe('
Create new resource
') 864 | }) 865 | 866 | it('Should return 200 response - /resource/abcdef', async () => { 867 | const res = await app.request('/resource/abcdef') 868 | expect(res.status).toBe(200) 869 | expect(await res.text()).toBe('Resource Id abcdef') 870 | }) 871 | 872 | it('Should return 200 response - /resource/abcdef/resource2', async () => { 873 | const res = await app.request('/resource/abcdef/resource2') 874 | expect(res.status).toBe(200) 875 | expect(await res.text()).toBe('Resource2 Home') 876 | }) 877 | 878 | it('Should return 200 response - /resource/abcdef/resource2/new', async () => { 879 | const res = await app.request('/resource/abcdef/resource2/new') 880 | expect(res.status).toBe(200) 881 | expect(await res.text()).toBe('
Create new resource 2
') 882 | }) 883 | 884 | it('Should return 200 response - /resource/abcdef/resource2/12345', async () => { 885 | const res = await app.request('/resource/abcdef/resource2/12345') 886 | expect(res.status).toBe(200) 887 | expect(await res.text()).toBe('Resource2 Id abcdef / 12345') 888 | }) 889 | }) 890 | 891 | describe('Function Component Response', () => { 892 | const ROUTES = import.meta.glob('../mocks/app-function/routes/**/[a-z[-][a-z[_-]*.(tsx|ts)', { 893 | eager: true, 894 | }) 895 | 896 | const app = createApp({ 897 | root: '../mocks/app-function/routes', 898 | ROUTES: ROUTES as any, 899 | }) 900 | 901 | it('Should handle direct Response return from function component', async () => { 902 | const res = await app.request('/api-response') 903 | expect(res.status).toBe(200) 904 | expect(await res.json()).toEqual({ message: 'API Response' }) 905 | }) 906 | 907 | it('Should handle JSX return from function component', async () => { 908 | const res = await app.request('/jsx-response') 909 | expect(res.status).toBe(200) 910 | expect(await res.text()).toBe('
JSX Response
') 911 | }) 912 | 913 | it('Should handle async function component with Response', async () => { 914 | const res = await app.request('/async-response') 915 | expect(res.status).toBe(201) 916 | expect(res.headers.get('x-custom')).toBe('async') 917 | expect(await res.json()).toEqual({ message: 'Async Response' }) 918 | }) 919 | 920 | it('Should handle async function component with JSX', async () => { 921 | const res = await app.request('/async-jsx') 922 | expect(res.status).toBe(200) 923 | expect(await res.text()).toBe('
Async JSX Response
') 924 | }) 925 | }) 926 | 927 | describe('Route Groups', () => { 928 | const ROUTES = import.meta.glob( 929 | '../mocks/app-route-groups/routes/**/[a-z[-][a-z[_-]*.(tsx|ts|mdx)', 930 | { 931 | eager: true, 932 | } 933 | ) 934 | const RENDERER = import.meta.glob('../mocks/app-route-groups/routes/**/_renderer.tsx', { 935 | eager: true, 936 | }) 937 | const NOT_FOUND = import.meta.glob('../mocks/app-route-groups/routes/**/_404.(ts|tsx)', { 938 | eager: true, 939 | }) 940 | const MIDDLEWARE = import.meta.glob('../mocks/app-route-groups/routes/**/_middleware.(tsx|ts)', { 941 | eager: true, 942 | }) 943 | 944 | const app = createApp({ 945 | root: '../mocks/app-route-groups/routes', 946 | ROUTES: ROUTES as any, 947 | RENDERER: RENDERER as any, 948 | NOT_FOUND: NOT_FOUND as any, 949 | MIDDLEWARE: MIDDLEWARE as any, 950 | init: (app) => { 951 | app.use('*', poweredBy()) 952 | }, 953 | }) 954 | 955 | it('Should have correct routes', () => { 956 | const routes: { path: string; method: string }[] = [ 957 | { 958 | path: '/*', 959 | method: 'ALL', 960 | }, 961 | { 962 | path: '/', 963 | method: 'GET', 964 | }, 965 | { 966 | path: '/blog', 967 | method: 'GET', 968 | }, 969 | { 970 | path: '/blog/hello-world', 971 | method: 'GET', 972 | }, 973 | ] 974 | expect(app.routes).toHaveLength(13) 975 | expect(app.routes).toEqual( 976 | expect.arrayContaining( 977 | routes.map(({ path, method }) => { 978 | return { 979 | path, 980 | method, 981 | handler: expect.any(Function), 982 | } 983 | }) 984 | ) 985 | ) 986 | }) 987 | 988 | it('Should render /blog without (content) route group layout', async () => { 989 | const res = await app.request('/blog') 990 | expect(res.status).toBe(200) 991 | expect(res.headers.get('x-message')).not.toBe('from middleware for (content-group)') 992 | expect(await res.text()).toBe( 993 | '
Here lies the blog posts
' 994 | ) 995 | }) 996 | 997 | it('Should render /blog/hello-world MDX with (content) route group layout', async () => { 998 | const res = await app.request('/blog/hello-world') 999 | expect(res.status).toBe(200) 1000 | expect(res.headers.get('x-message')).toBe('from middleware for (content-group)') 1001 | expect(await res.text()).toBe( 1002 | '

Blog

Hello World

' 1003 | ) 1004 | }) 1005 | }) 1006 | -------------------------------------------------------------------------------- /test-integration/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import mdx from '@mdx-js/rollup' 2 | import tsconfigPaths from 'vite-tsconfig-paths' 3 | import { defineConfig } from 'vitest/config' 4 | import path from 'path' 5 | import { injectImportingIslands } from '../src/vite/inject-importing-islands' 6 | import { islandComponents } from '../src/vite/island-components' 7 | 8 | const appDir = '/mocks' 9 | const islandDir = '/mocks/[^/]+/islands' 10 | 11 | export default defineConfig({ 12 | test: { 13 | globals: true, 14 | }, 15 | resolve: { 16 | alias: { 17 | '@': path.resolve(__dirname, '../mocks/app-alias'), 18 | 'honox/vite': path.resolve(__dirname, '../src/vite'), 19 | }, 20 | }, 21 | plugins: [ 22 | islandComponents({ 23 | islandDir, 24 | }), 25 | injectImportingIslands({ 26 | islandDir, 27 | appDir, 28 | }), 29 | mdx({ 30 | jsxImportSource: 'hono/jsx', 31 | }), 32 | tsconfigPaths(), 33 | ], 34 | }) 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "types": [ 11 | "node", 12 | "vite/client", 13 | "vitest/globals" 14 | ], 15 | "jsx": "react-jsx", 16 | "jsxImportSource": "hono/jsx", 17 | "baseUrl": ".", 18 | "paths": { 19 | "@mocks/*": [ 20 | "./mocks/app-alias-tsconfig-paths/*" 21 | ] 22 | } 23 | }, 24 | "include": [ 25 | "src", 26 | "test-integration", 27 | "test-e2e", 28 | "mocks" 29 | ], 30 | "exclude": [ 31 | "mocks/app-alias" 32 | ] 33 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { glob } from 'glob' 2 | import { defineConfig } from 'tsup' 3 | 4 | const entryPoints = glob.sync('./src/**/*.+(ts|tsx|json)', { 5 | posix: true, 6 | ignore: ['./src/**/*.test.+(ts|tsx)'], 7 | }) 8 | 9 | export default defineConfig({ 10 | entry: entryPoints, 11 | dts: true, 12 | splitting: false, 13 | minify: false, 14 | format: ['esm'], 15 | bundle: false, 16 | platform: 'node', 17 | }) 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'happy-dom', 7 | exclude: ['node_modules', 'dist', '.git', '.cache', 'test-presets', 'sandbox', 'examples'], 8 | }, 9 | }) 10 | --------------------------------------------------------------------------------