Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ hono serve
- `search <query>` - Search Hono documentation
- `request [file]` - Send request to Hono app using `app.request()`
- `serve [entry]` - Start server for Hono app
- `optimize [entry]` - generate an optimized Hono app

### `docs`

Expand Down Expand Up @@ -231,6 +232,32 @@ hono serve \
--use "serveStatic({ root: './' })"
```

### `optimize`

Generate an optimized Hono class and export bundled file.

```bash
hono optimize [entry] [options]
```

**Arguments:**

- `entry` - Entry file for your Hono app (TypeScript/JSX supported, optional)

**Options:**

- `-o, --outfile <outfile>` - Output file

**Examples:**

```bash
# Generate an optimized Hono class and export bundled file to dist/index.js
hono optimize

# Specify entry file and output file
hono optimize -o dist/app.js src/app.ts
```

## Tips

### Using Hono CLI with AI Code Agents
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@
"vitest": "^3.2.4"
},
"packageManager": "bun@1.2.20"
}
}
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { docsCommand } from './commands/docs/index.js'
import { optimizeCommand } from './commands/optimize/index.js'
import { requestCommand } from './commands/request/index.js'
import { searchCommand } from './commands/search/index.js'
import { serveCommand } from './commands/serve/index.js'
Expand All @@ -22,6 +23,7 @@ program

// Register commands
docsCommand(program)
optimizeCommand(program)
searchCommand(program)
requestCommand(program)
serveCommand(program)
Expand Down
204 changes: 204 additions & 0 deletions src/commands/optimize/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Command } from 'commander'
import { describe, it, expect, beforeEach } from 'vitest'
import { execFile } from 'node:child_process'
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { optimizeCommand } from './index'

const program = new Command()
optimizeCommand(program)

const npmInstall = async () =>
new Promise<void>((resolve) => {
const child = execFile('npm', ['install'])
child.on('exit', () => {
resolve()
})
})

describe('optimizeCommand', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'hono-cli-optimize-test'))
mkdirSync(join(dir, 'src'), { recursive: true })
process.chdir(dir)
})

it('should throws an error if entry file not found', async () => {
await expect(
program.parseAsync(['node', 'hono', 'optimize', './non-existent-file.ts'])
).rejects.toThrowError()
})

it.each([
{
name: 'src/index.ts',
files: [
{
path: './src/index.ts',
content: `
import { Hono } from 'hono'
const app = new Hono<{ Bindings: { FOO: string } }>()
app.get('/', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'src/index.tsx',
files: [
{
path: './src/index.tsx',
content: `
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.html(<div>Hello, World!</div>))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'src/index.js',
files: [
{
path: './src/index.js',
content: `
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'src/index.jsx',
files: [
{
path: './src/index.jsx',
content: `
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.html(<div>Hello, World!</div>))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'specify entry file',
args: ['./src/app.ts'],
files: [
{
path: './src/app.ts',
content: `
import { Hono } from 'hono'
const app = new Hono<{ Bindings: { FOO: string } }>()
app.get('/', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'specify outfile option',
args: ['-o', './dist/app.js'],
files: [
{
path: './src/index.ts',
content: `
import { Hono } from 'hono'
const app = new Hono<{ Bindings: { FOO: string } }>()
app.get('/', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/app.js',
content: `this.router = new PreparedRegExpRouter(...routerParams)`,
},
},
{
name: 'fallback to TrieRouter',
files: [
{
path: './src/index.ts',
content: `
import { Hono } from 'hono'
const app = new Hono<{ Bindings: { FOO: string } }>()
app.get('/foo/:capture{ba(r|z)}', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new TrieRouter()`,
},
},
{
name: 'hono@4.9.10 does not have PreparedRegExpRouter',
honoVersion: '4.9.10',
files: [
{
path: './src/index.ts',
content: `
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello, World!'))
export default app
`,
},
],
result: {
path: './dist/index.js',
content: `this.router = new RegExpRouter()`,
},
},
])(
'should success to optimize: $name',
{ timeout: 0 },
async ({ honoVersion, files, result, args }) => {
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({
name: 'hono-cli-optimize-test',
type: 'module',
dependencies: {
hono: honoVersion ?? '4.9.11',
},
})
)
await npmInstall()
for (const file of files) {
writeFileSync(join(dir, file.path), file.content)
}
await program.parseAsync(['node', 'hono', 'optimize', ...(args ?? [])])
expect(readFileSync(join(dir, result.path), 'utf-8')).toMatch(result.content)
}
)
})
120 changes: 120 additions & 0 deletions src/commands/optimize/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Command } from 'commander'
import * as esbuild from 'esbuild'
import type { Hono } from 'hono'
import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router'
import { execFile } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { buildAndImportApp } from '../../utils/build.js'

const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx']

export function optimizeCommand(program: Command) {
program
.command('optimize')
.description('Build optimized Hono class')
.argument('[entry]', 'entry file')
.option('-o, --outfile [outfile]', 'output file', 'dist/index.js')
.action(async (entry: string, options: { outfile: string }) => {
if (!entry) {
entry =
DEFAULT_ENTRY_CANDIDATES.find((entry) => existsSync(entry)) ?? DEFAULT_ENTRY_CANDIDATES[0]
}

const appPath = resolve(process.cwd(), entry)

if (!existsSync(appPath)) {
throw new Error(`Entry file ${entry} does not exist`)
}

const appFilePath = realpathSync(appPath)
const app: Hono = await buildAndImportApp(appFilePath, {
external: ['@hono/node-server'],
})

let importStatement
let assignRouterStatement
try {
const serialized = serializeInitParams(
buildInitParams({
paths: app.routes.map(({ path }) => path),
})
)

const hasPreparedRegExpRouter = await new Promise<boolean>((resolve) => {
const child = execFile(process.execPath, [
'--input-type=module',
'-e',
"try { (await import('hono/router/reg-exp-router')).PreparedRegExpRouter && process.exit(0) } finally { process.exit(1) }",
])
child.on('exit', (code) => {
resolve(code === 0)
})
})

if (hasPreparedRegExpRouter) {
importStatement = "import { PreparedRegExpRouter } from 'hono/router/reg-exp-router'"
assignRouterStatement = `const routerParams = ${serialized}
this.router = new PreparedRegExpRouter(...routerParams)`
} else {
importStatement = "import { RegExpRouter } from 'hono/router/reg-exp-router'"
assignRouterStatement = 'this.router = new RegExpRouter()'
}
} catch {
// fallback to default router
importStatement = "import { TrieRouter } from 'hono/router/trie-router'"
assignRouterStatement = 'this.router = new TrieRouter()'
}

await esbuild.build({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about displaying a message like "Generated the optimized file into dist" using something like console.log?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this?

CleanShot 2025-10-16 at 13 13 28@2x

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Displaying the router name and the file size is super cool!!

And it's a matter of taste, but shall we go without using emojis? I've never used it in this Hono CLI project. So how about the following:

CleanShot 2025-10-16 at 15 30 46@2x

Diff:

diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts
index 7262cd4..8a8dd04 100644
--- a/src/commands/optimize/index.ts
+++ b/src/commands/optimize/index.ts
@@ -71,7 +71,8 @@ export function optimizeCommand(program: Command) {
         assignRouterStatement = 'this.router = new TrieRouter()'
       }

-      console.log(`⚡️Router: ${routerName}`)
+      console.log('[Optimized]')
+      console.log(`  Router: ${routerName}`)

       const outfile = resolve(process.cwd(), options.outfile)
       await esbuild.build({
@@ -127,6 +128,6 @@ export class Hono extends HonoBase {
       })

       const outfileStat = statSync(outfile)
-      console.log(`🔥App: ${options.outfile} (${(outfileStat.size / 1024).toFixed(2)} KB)`)
+      console.log(`  Output: ${options.outfile} (${(outfileStat.size / 1024).toFixed(2)} KB)`)
     })
 }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment.
Okay, let's do that.
75596ab

entryPoints: [appFilePath],
outfile: resolve(process.cwd(), options.outfile),
bundle: true,
format: 'esm',
target: 'node20',
platform: 'node',
jsx: 'automatic',
jsxImportSource: 'hono/jsx',
plugins: [
{
name: 'hono-optimize',
setup(build) {
const honoPseudoImportPath = 'hono-optimized-pseudo-import-path'

build.onResolve({ filter: /^hono$/ }, async (args) => {
if (!args.importer) {
// prevent recursive resolution of "hono"
return undefined
}

// resolve original import path for "hono"
const resolved = await build.resolve(args.path, {
kind: 'import-statement',
resolveDir: args.resolveDir,
})

// mark "honoOptimize" to the resolved path for filtering
return {
path: join(dirname(resolved.path), honoPseudoImportPath),
}
})
build.onLoad({ filter: new RegExp(`/${honoPseudoImportPath}$`) }, async () => {
return {
contents: `
import { HonoBase } from 'hono/hono-base'
${importStatement}
export class Hono extends HonoBase {
constructor(options = {}) {
super(options)
${assignRouterStatement}
}
}
`,
}
})
},
},
],
})
})
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
}
}
}