├── .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 |
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 ``.
302 |
303 | ```tsx
304 | // app/routes/_renderer.tsx
305 | import { jsxRenderer } from 'hono/jsx-renderer'
306 | import { Script } from 'honox/server'
307 |
308 | export default jsxRenderer(({ children }) => {
309 | return (
310 |
311 |
312 |
313 |
314 |
315 |
316 | {children}
317 |
318 | )
319 | })
320 | ```
321 |
322 | **Note**: Since ` ` can slightly affect build performance when used, it is recommended that you do not use it in the development environment, but only at build time. `` does not cause performance degradation during development, so it's better to use it.
323 |
324 | #### nonce Attribute
325 |
326 | If you want to add a `nonce` attribute to `` or `` element, you can use [Security Headers Middleware](https://hono.dev/middleware/builtin/secure-headers).
327 |
328 | Define the middleware:
329 |
330 | ```ts
331 | // app/routes/_middleware.ts
332 | import { createRoute } from 'honox/factory'
333 | import { secureHeaders, NONCE } from 'hono/secure-headers'
334 |
335 | export default createRoute(
336 | secureHeaders({
337 | contentSecurityPolicy: {
338 | scriptSrc: [NONCE],
339 | },
340 | })
341 | )
342 | ```
343 |
344 | You can get the `nonce` value with `c.get('secureHeadersNonce')`:
345 |
346 | ```tsx
347 | // app/routes/_renderer.tsx
348 | import { jsxRenderer } from 'hono/jsx-renderer'
349 | import { Script } from 'honox/server'
350 |
351 | export default jsxRenderer(({ children }, c) => {
352 | return (
353 |
354 |
355 |
356 |
357 | {children}
358 |
359 | )
360 | })
361 | ```
362 |
363 | ### Client Entry File
364 |
365 | A client-side entry file should be in `app/client.ts`. Simply, write `createClient()`.
366 |
367 | ```ts
368 | // app/client.ts
369 | import { createClient } from 'honox/client'
370 |
371 | createClient()
372 | ```
373 |
374 | ### Interactions
375 |
376 | If you want to add interactions to your page, create Island components. Islands components should be:
377 |
378 | - Placed under `app/islands` directory or named with `$` prefix like `$componentName.tsx`.
379 | - It should be exported as a `default` or a proper component name that uses camel case but does not contain `_` and is not all uppercase.
380 |
381 | For example, you can write an interactive component such as the following counter:
382 |
383 | ```tsx
384 | // app/islands/counter.tsx
385 | import { useState } from 'hono/jsx'
386 |
387 | export default function Counter() {
388 | const [count, setCount] = useState(0)
389 | return (
390 |
391 |
Count: {count}
392 |
setCount(count + 1)}>Increment
393 |
394 | )
395 | }
396 | ```
397 |
398 | When you load the component in a route file, it is rendered as Server-Side rendering and JavaScript is also sent to the client side.
399 |
400 | ```tsx
401 | // app/routes/index.tsx
402 | import { createRoute } from 'honox/factory'
403 | import Counter from '../islands/counter'
404 |
405 | export default createRoute((c) => {
406 | return c.render(
407 |
408 |
Hello
409 |
410 |
411 | )
412 | })
413 | ```
414 |
415 | **Note**: You cannot access a Context object in Island components. Therefore, you should pass the value from components outside of the Island.
416 |
417 | ```ts
418 | import { useRequestContext } from 'hono/jsx-renderer'
419 | import Counter from '../islands/counter.tsx'
420 |
421 | export default function Component() {
422 | const c = useRequestContext()
423 | return
424 | }
425 | ```
426 |
427 | ## BYOR - Bring Your Own Renderer
428 |
429 | You can bring your own renderer using a UI library like React, Preact, Solid, or others.
430 |
431 | **Note**: We may not provide support for the renderer you bring.
432 |
433 | ### React case
434 |
435 | You can define a renderer using [`@hono/react-renderer`](https://github.com/honojs/middleware/tree/main/packages/react-renderer). Install the modules first.
436 |
437 | ```txt
438 | npm i @hono/react-renderer react react-dom hono
439 | npm i -D @types/react @types/react-dom
440 | ```
441 |
442 | Define the Props that the renderer will receive in `global.d.ts`.
443 |
444 | ```ts
445 | // global.d.ts
446 | import '@hono/react-renderer'
447 |
448 | declare module '@hono/react-renderer' {
449 | interface Props {
450 | title?: string
451 | }
452 | }
453 | ```
454 |
455 | The following is an example of `app/routes/_renderer.tsx`.
456 |
457 | ```tsx
458 | // app/routes/_renderer.tsx
459 | import { reactRenderer } from '@hono/react-renderer'
460 |
461 | export default reactRenderer(({ children, title }) => {
462 | return (
463 |
464 |
465 |
466 |
467 | {import.meta.env.PROD ? (
468 |
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 ``
547 |
548 | If you export a manifest file in `dist/.vite/manifest.json`, you can easily write some codes using ``.
549 |
550 | ```tsx
551 | // app/routes/_renderer.tsx
552 | import { reactRenderer } from '@hono/react-renderer'
553 | import { Script } from 'honox/server'
554 |
555 | export default reactRenderer(({ children, title }) => {
556 | return (
557 |
558 |
559 |
560 |
561 |
562 | {title ? {title} : ''}
563 |
564 | {children}
565 |
566 | )
567 | })
568 | ```
569 |
570 | Configure react in `vite.config.ts`.
571 |
572 | ```ts
573 | // vite.config.ts
574 | import build from '@hono/vite-build/cloudflare-pages'
575 | import honox from 'honox/vite'
576 | import { defineConfig } from 'vite'
577 |
578 | export default defineConfig(({ mode }) => {
579 | if (mode === 'client') {
580 | return {
581 | build: {
582 | rollupOptions: {
583 | input: ['./app/client.ts'],
584 | },
585 | manifest: true,
586 | emptyOutDir: false,
587 | },
588 | }
589 | } else {
590 | return {
591 | ssr: {
592 | external: ['react', 'react-dom'],
593 | },
594 | plugins: [honox(), build()],
595 | }
596 | }
597 | })
598 | ```
599 |
600 | ## Guides
601 |
602 | ### Nested Layouts
603 |
604 | If you are using the JSX Renderer middleware, you can nest layouts using ` `.
605 |
606 | ```tsx
607 | // app/routes/posts/_renderer.tsx
608 |
609 | import { jsxRenderer } from 'hono/jsx-renderer'
610 |
611 | export default jsxRenderer(({ children, Layout }) => {
612 | return (
613 |
614 | Posts Menu
615 | {children}
616 |
617 | )
618 | })
619 | ```
620 |
621 | #### Passing Additional Props in Nested Layouts
622 |
623 | Props passed to nested renderers do not automatically propagate to the parent renderers. To ensure that the parent layouts receive the necessary props, you should explicitly pass them from the nested component. Here's how you can achieve that:
624 |
625 | Let's start with our route handler:
626 |
627 | ```tsx
628 | // app/routes/nested/index.tsx
629 | export default createRoute((c) => {
630 | return c.render(Content
, { title: 'Dashboard' })
631 | })
632 | ```
633 |
634 | Now, let's take a look at our nested renderer:
635 |
636 | ```tsx
637 | // app/routes/nested/_renderer.tsx
638 | export default jsxRenderer(({ children, Layout, title }) => {
639 | return (
640 |
641 | {/* Pass the title prop to the parent renderer */}
642 | {children}
643 |
644 | )
645 | })
646 | ```
647 |
648 | In this setup, all the props sent to the nested renderer's are consumed by the parent renderer:
649 |
650 | ```tsx
651 | // app/routes/_renderer.tsx
652 | export default jsxRenderer(({ children, title }) => {
653 | return (
654 |
655 |
656 | {title} {/* Use the title prop here */}
657 |
658 |
659 | {children} {/* Insert the Layout's children here */}
660 |
661 |
662 | )
663 | })
664 | ```
665 |
666 | ### Using Middleware
667 |
668 | You can use Hono's Middleware in each root file with the same syntax as Hono. For example, to validate a value with the [Zod Validator](https://github.com/honojs/middleware/tree/main/packages/zod-validator), do the following:
669 |
670 | ```tsx
671 | import { z } from 'zod'
672 | import { zValidator } from '@hono/zod-validator'
673 |
674 | const schema = z.object({
675 | name: z.string().max(10),
676 | })
677 |
678 | export const POST = createRoute(zValidator('form', schema), async (c) => {
679 | const { name } = c.req.valid('form')
680 | setCookie(c, 'name', name)
681 | return c.redirect('/')
682 | })
683 | ```
684 |
685 | Alternatively, you can use a `_middleware.(ts|tsx)` file in a directory to have that middleware applied to the current route, as well as all child routes. Middleware is run in the order that it is listed within the array.
686 |
687 | ```ts
688 | // /app/routes/_middleware.ts
689 | import { createRoute } from 'honox/factory'
690 | import { logger } from 'hono/logger'
691 | import { secureHeaders } from 'hono/secure-headers'
692 |
693 | export default createRoute(logger(), secureHeaders(), ...)
694 | ```
695 |
696 | ### Trailing Slash
697 |
698 | By default, trailing slashes are removed if the root file is an index file such as `index.tsx` or `index.mdx`.
699 | However, if you set the `trailingSlash` option to `true` as the following, the trailing slash is not removed.
700 |
701 | ```ts
702 | import { createApp } from 'honox/server'
703 |
704 | const app = createApp({
705 | trailingSlash: true,
706 | })
707 | ```
708 |
709 | Like the followings:
710 |
711 | - `trailingSlash` is `false` (default): `app/routes/path/index.mdx` => `/path`
712 | - `trailingSlash` is `true`: `app/routes/path/index.mdx` => `/path/`
713 |
714 | ### Excluding Files and Directories from Routes
715 |
716 | By default, directories and files starting with `-` are excluded from routes.
717 |
718 | Example:
719 |
720 | ```
721 | routes/
722 | ├── posts.tsx
723 | ├── -post-list.tsx // 👈🏼 ignored
724 | ├── -components/ // 👈🏼 ignored
725 | │ ├── header.tsx // 👈🏼 ignored
726 | │ ├── footer.tsx // 👈🏼 ignored
727 | │ └── ...
728 | ```
729 |
730 | In this example, `routes/posts.tsx` is routed to `/posts`, but other items starting with `-` are not routed.
731 |
732 | This feature is useful for colocation.
733 |
734 | ### Using Tailwind CSS
735 |
736 | Given that HonoX is Vite-centric, if you wish to utilize [Tailwind CSS](https://tailwindcss.com/), basically adhere to [the official instructions](https://tailwindcss.com/docs/installation/using-vite).
737 |
738 | Write `app/style.css`, you must set the base path for source detection explicitly:
739 |
740 | ```css
741 | @import 'tailwindcss' source('../app');
742 | ```
743 |
744 | Import it in a renderer file. Using the `Link` component will refer to the correct CSS file path after it is built.
745 |
746 | ```tsx
747 | // app/routes/_renderer.tsx
748 | import { jsxRenderer } from 'hono/jsx-renderer'
749 | import { Link } from 'honox/server'
750 |
751 | export default jsxRenderer(({ children }) => {
752 | return (
753 |
754 |
755 |
756 |
757 |
758 |
759 | {children}
760 |
761 | )
762 | })
763 | ```
764 |
765 | Finally, add `vite.config.ts` configuration to output assets for the production.
766 |
767 | ```ts
768 | import honox from 'honox/vite'
769 | import { defineConfig } from 'vite'
770 | import build from '@hono/vite-build/cloudflare-pages'
771 | import tailwindcss from '@tailwindcss/vite'
772 |
773 | export default defineConfig({
774 | plugins: [
775 | honox({
776 | client: {
777 | input: ['/app/style.css'],
778 | },
779 | }),
780 | build(),
781 | tailwindcss(),
782 | ],
783 | })
784 | ```
785 |
786 | ### MDX
787 |
788 | MDX can also be used. Here is the `vite.config.ts`.
789 |
790 | ```ts
791 | import devServer from '@hono/vite-dev-server'
792 | import mdx from '@mdx-js/rollup'
793 | import honox from 'honox/vite'
794 | import remarkFrontmatter from 'remark-frontmatter'
795 | import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
796 | import { defineConfig } from 'vite'
797 |
798 | export default defineConfig(() => {
799 | return {
800 | plugins: [
801 | honox(),
802 | mdx({
803 | jsxImportSource: 'hono/jsx',
804 | remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
805 | }),
806 | ],
807 | }
808 | })
809 | ```
810 |
811 | Blog site can be created.
812 |
813 | ```tsx
814 | // app/routes/index.tsx
815 | import type { Meta } from '../types'
816 |
817 | export default function Top() {
818 | const posts = import.meta.glob<{ frontmatter: Meta }>('./posts/*.mdx', {
819 | eager: true,
820 | })
821 | return (
822 |
823 |
Posts
824 |
825 | {Object.entries(posts).map(([id, module]) => {
826 | if (module.frontmatter) {
827 | return (
828 |
829 | {module.frontmatter.title}
830 |
831 | )
832 | }
833 | })}
834 |
835 |
836 | )
837 | }
838 | ```
839 |
840 | ### Cloudflare Bindings
841 |
842 | If you want to use Cloudflare's Bindings in your development environment, create `wrangler.toml` and configure it properly.
843 |
844 | ```toml
845 | name = "my-project-name"
846 | compatibility_date = "2024-04-01"
847 | compatibility_flags = [ "nodejs_compat" ]
848 | pages_build_output_dir = "./dist"
849 |
850 | # [vars]
851 | # MY_VARIABLE = "production_value"
852 |
853 | # [[kv_namespaces]]
854 | # binding = "MY_KV_NAMESPACE"
855 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
856 | ```
857 |
858 | In `vite.config.ts`, use the Cloudflare Adapter in `@hono/vite-dev-server`.
859 |
860 | ```ts
861 | import honox from 'honox/vite'
862 | import adapter from '@hono/vite-dev-server/cloudflare'
863 | import { defineConfig } from 'vite'
864 |
865 | export default defineConfig({
866 | plugins: [
867 | honox({
868 | devServer: {
869 | adapter,
870 | },
871 | }),
872 | ],
873 | })
874 | ```
875 |
876 | ## Deployment
877 |
878 | Since a HonoX instance is essentially a Hono instance, it can be deployed on any platform that Hono supports.
879 |
880 | ### Cloudflare Pages
881 |
882 | Add the `wrangler.toml`:
883 |
884 | ```toml
885 | # wrangler.toml
886 | name = "my-project-name"
887 | compatibility_date = "2024-04-01"
888 | compatibility_flags = [ "nodejs_compat" ]
889 | pages_build_output_dir = "./dist"
890 | ```
891 |
892 | Setup the `vite.config.ts`:
893 |
894 | ```ts
895 | // vite.config.ts
896 | import { defineConfig } from 'vite'
897 | import honox from 'honox/vite'
898 | import build from '@hono/vite-build/cloudflare-pages'
899 |
900 | export default defineConfig({
901 | plugins: [honox(), build()],
902 | })
903 | ```
904 |
905 | Build command (including a client):
906 |
907 | ```txt
908 | vite build --mode client && vite build
909 | ```
910 |
911 | Deploy with the following commands after the build. Ensure you have [Wrangler](https://developers.cloudflare.com/workers/wrangler/) installed:
912 |
913 | ```txt
914 | wrangler pages deploy
915 | ```
916 |
917 | ### SSG - Static Site Generation
918 |
919 | Using Hono's SSG feature, you can generate static HTML for each route.
920 |
921 | ```ts
922 | import { defineConfig } from 'vite'
923 | import honox from 'honox/vite'
924 | import ssg from '@hono/vite-ssg'
925 |
926 | const entry = './app/server.ts'
927 |
928 | export default defineConfig(() => {
929 | return {
930 | plugins: [honox(), ssg({ entry })],
931 | }
932 | })
933 | ```
934 |
935 | If you want to include client-side scripts and assets:
936 |
937 | ```ts
938 | // vite.config.ts
939 | import ssg from '@hono/vite-ssg'
940 | import honox from 'honox/vite'
941 | import client from 'honox/vite/client'
942 | import { defineConfig } from 'vite'
943 |
944 | const entry = './app/server.ts'
945 |
946 | export default defineConfig(({ mode }) => {
947 | if (mode === 'client') {
948 | return {
949 | plugins: [client()],
950 | }
951 | } else {
952 | return {
953 | build: {
954 | emptyOutDir: false,
955 | },
956 | plugins: [honox(), ssg({ entry })],
957 | }
958 | }
959 | })
960 | ```
961 |
962 | Build command (including a client):
963 |
964 | ```txt
965 | vite build --mode client && vite build
966 | ```
967 |
968 | You can also deploy it to Cloudflare Pages.
969 |
970 | ```txt
971 | wrangler pages deploy ./dist
972 | ```
973 |
974 | ### Others
975 |
976 | Using `@hono/vite-build`, you can build the HonoX app for various platforms. For example, you can make it for the Bun:
977 |
978 | ```ts
979 | // vite.config.ts
980 | import { defineConfig } from 'vite'
981 | import honox from 'honox/vite'
982 | import build from '@hono/vite-build/bun'
983 |
984 | export default defineConfig({
985 | plugins: [honox(), build()],
986 | })
987 | ```
988 |
989 | ## Examples
990 |
991 | - https://github.com/yusukebe/honox-examples
992 |
993 | ## Related projects
994 |
995 | - [Hono](https://hono.dev/)
996 | - [Vite](https://vitejs.dev/)
997 |
998 | ## Authors
999 |
1000 | - Yusuke Wada
1001 |
1002 | ## License
1003 |
1004 | MIT
1005 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import baseConfig from '@hono/eslint-config'
2 |
3 | export default [...baseConfig]
4 |
--------------------------------------------------------------------------------
/mocks/api/routes/about/[name].ts:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono'
2 |
3 | const app = new Hono()
4 |
5 | app.get('/', (c) => {
6 | const name = c.req.param('name')
7 | return c.json({
8 | path: `/about/${name}`,
9 | })
10 | })
11 |
12 | app.get('/address', (c) => {
13 | const name = c.req.param('name')
14 | return c.json({
15 | path: `/about/${name}/address`,
16 | })
17 | })
18 |
19 | export default app
20 |
--------------------------------------------------------------------------------
/mocks/api/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono'
2 |
3 | const app = new Hono()
4 |
5 | app.get('/', (c) =>
6 | c.json({
7 | path: '/',
8 | })
9 | )
10 |
11 | app.get('/foo', (c) =>
12 | c.json({
13 | path: '/foo',
14 | })
15 | )
16 |
17 | export default app
18 |
--------------------------------------------------------------------------------
/mocks/api/routes/middleware/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono'
2 | import { poweredBy } from 'hono/powered-by'
3 |
4 | const app = new Hono()
5 |
6 | app.use('*', poweredBy())
7 |
8 | app.get('/', (c) =>
9 | c.json({
10 | path: '/middleware',
11 | })
12 | )
13 |
14 | app.get('/foo', (c) =>
15 | c.json({
16 | path: '/middleware/foo',
17 | })
18 | )
19 |
20 | export default app
21 |
--------------------------------------------------------------------------------
/mocks/app-alias-tsconfig-paths/SomeComponent.tsx:
--------------------------------------------------------------------------------
1 | import Counter from './islands/Counter'
2 |
3 | export default function SomeComponent() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/mocks/app-alias-tsconfig-paths/islands/Counter.tsx:
--------------------------------------------------------------------------------
1 | export default function Counter() {
2 | return Counter
3 | }
4 |
--------------------------------------------------------------------------------
/mocks/app-alias-tsconfig-paths/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-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 |
Increment
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 | foo menu
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 | bar menu
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 |
20 |
21 |
22 | )
23 | },
24 | {
25 | docType: false,
26 | }
27 | )
28 |
--------------------------------------------------------------------------------
/mocks/app-script/routes/_nonce_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 |
20 |
21 |
22 | )
23 | },
24 | {
25 | docType: false,
26 | }
27 | )
28 |
--------------------------------------------------------------------------------
/mocks/app-script/routes/_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 |
18 |
19 | {children}
20 |
21 | )
22 | },
23 | {
24 | docType: false,
25 | }
26 | )
27 |
--------------------------------------------------------------------------------
/mocks/app-script/routes/classic/index.tsx:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono'
2 | import Component from '../../islands/Component'
3 |
4 | const app = new Hono()
5 |
6 | app.get('/', (c) => {
7 | return c.render(
8 |
9 |
10 |
11 | )
12 | })
13 |
14 | export default app
15 |
--------------------------------------------------------------------------------
/mocks/app-script/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import Component from '../islands/Component'
2 |
3 | export default function Hello() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/mocks/app/client.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '../../src/client'
2 |
3 | createClient()
4 |
5 | setTimeout(() => {
6 | document.body.setAttribute('data-client-loaded', 'true')
7 | })
8 |
--------------------------------------------------------------------------------
/mocks/app/components/$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 | }: PropsWithChildren<{
8 | initial?: number
9 | }>) {
10 | const [count, setCount] = useState(initial)
11 | const increment = () => setCount(count + 1)
12 | return (
13 |
14 |
Count: {count}
15 |
Increment
16 | {children}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/mocks/app/components/Badge.tsx:
--------------------------------------------------------------------------------
1 | export default function Badge({ name }: { name: string }) {
2 | return My name is {name}
3 | }
4 |
--------------------------------------------------------------------------------
/mocks/app/components/CounterCard.tsx:
--------------------------------------------------------------------------------
1 | import Counter from '../islands/Counter'
2 |
3 | export default function CounterCard({ title }: { title: string }) {
4 | return (
5 |
6 |
{title}
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/mocks/app/components/islands/not-island.tsx:
--------------------------------------------------------------------------------
1 | export default function NotIsland() {
2 | return Not Island
3 | }
4 |
--------------------------------------------------------------------------------
/mocks/app/global.d.ts:
--------------------------------------------------------------------------------
1 | import type {} from 'hono'
2 |
3 | declare module 'hono' {
4 | interface ContextRenderer {
5 | (content: string | Promise, head?: { title?: string }): Response
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/mocks/app/globals.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/honojs/honox/49897f9d47012bba9e6f590b38ca6405b642bf21/mocks/app/globals.css
--------------------------------------------------------------------------------
/mocks/app/islands/Badge.tsx:
--------------------------------------------------------------------------------
1 | export default function Badge({ name }: { name: string }) {
2 | return {name}
3 | }
4 |
--------------------------------------------------------------------------------
/mocks/app/islands/Counter.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, Child } from 'hono/jsx'
2 | import { useState } from 'hono/jsx'
3 | import Badge from './Badge'
4 |
5 | export default function Counter({
6 | children,
7 | initial = 0,
8 | id = '',
9 | slot,
10 | }: PropsWithChildren<{
11 | initial?: number
12 | id?: string
13 | slot?: Child
14 | }>) {
15 | const [count, setCount] = useState(initial)
16 | const increment = () => setCount(count + 1)
17 | return (
18 |
19 |
20 |
Count: {count}
21 |
Increment
22 | {children}
23 | {slot &&
{slot}
}
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/mocks/app/islands/NamedCounter.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'hono/jsx'
2 | import { useState } from 'hono/jsx'
3 | import Badge from './Badge'
4 |
5 | export function NamedCounter({
6 | children,
7 | initial = 0,
8 | id = '',
9 | }: PropsWithChildren<{
10 | initial?: number
11 | id?: string
12 | }>) {
13 | const [count, setCount] = useState(initial)
14 | const increment = () => setCount(count + 1)
15 | return (
16 |
17 |
18 |
Count: {count}
19 |
Increment
20 | {children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/mocks/app/routes/_404.tsx:
--------------------------------------------------------------------------------
1 | import type { NotFoundHandler } from 'hono'
2 |
3 | const handler: NotFoundHandler = (c) => {
4 | return c.render(Not Found , {
5 | title: 'Not Found',
6 | })
7 | }
8 |
9 | export default handler
10 |
--------------------------------------------------------------------------------
/mocks/app/routes/_error.tsx:
--------------------------------------------------------------------------------
1 | import type { ErrorHandler } from 'hono'
2 |
3 | const handler: ErrorHandler = (error, c) => {
4 | return c.render(Custom Error Message: {error.message} , {
5 | title: 'Internal Server Error',
6 | })
7 | }
8 |
9 | export default handler
10 |
--------------------------------------------------------------------------------
/mocks/app/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/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 |
Dollar Increment
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 |
UnderScore Increment
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 |
13 | Sync child
14 |
15 |
16 |
17 |
18 |
19 |
20 | Child Counter
21 |
22 |
23 |
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 |
61 |
62 | {children}
63 |
64 |
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 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 '
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: 10
Increment Counter
Count: 15
Increment Counter
Count: 15
Increment Counter
Count: 30
Increment Counter
Count: 20
Increment Counter
Count: 30
Increment Counter
Count: 25
Increment Counter
Count: 25
Increment Counter
Count: 30
Increment '
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 | ' '
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 |
Increment
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
UnderScore Increment DollarCount: 5
Dollar Increment '
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 menu 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('foo menu bar menu 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('foo menu bar menu 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', () => {
472 | const ROUTES = import.meta.glob('../mocks/app-script/routes/**/index.tsx', {
473 | eager: true,
474 | })
475 |
476 | describe('default', () => {
477 | const RENDERER = import.meta.glob('../mocks/app-script/routes/**/_renderer.tsx', {
478 | eager: true,
479 | })
480 |
481 | const app = createApp({
482 | root: '../mocks/app-script/routes',
483 | ROUTES: ROUTES as any,
484 | RENDERER: RENDERER as any,
485 | })
486 |
487 | it('Should convert the script path correctly', async () => {
488 | const res = await app.request('/')
489 | expect(res.status).toBe(200)
490 | expect(await res.text()).toBe(
491 | '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 '
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 '
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 | '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 | ' '
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 |
--------------------------------------------------------------------------------