Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
74 changes: 53 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ hono --help
# Send request to Hono app
hono request

# Generate an optimized Hono app
hono optimize
# Build your Hono app
hono build
```

## Commands

- `request [file]` - Send request to Hono app using `app.request()`
- `optimize [entry]` - Generate an optimized Hono app
- `build [entry]` - Build your Hono app

### `request`

Expand Down Expand Up @@ -96,20 +96,21 @@ The command returns a JSON object with the following structure:
}
```

### `optimize`
### `build`

Generate an optimized Hono class and export bundled file.
Build your Hono app into a single bundled file.

This command automatically applies the following optimizations to reduce bundle size:
```bash
hono build [entry] [options]
```

With the `--optimize` option, it also applies Hono-specific optimizations to reduce bundle size:

- **Router optimization**: Replaces the router with a prebuilt router for your routes
- **Request body API removal**: Removes request body APIs (`c.req.json()`, `c.req.formData()`, etc.) when your application only uses GET, HEAD, or OPTIONS methods
- **Context response API removal**: Removes unused response utility APIs (`c.body()`, `c.json()`, `c.text()`, `c.html()`, `c.redirect()`) from Context object
- **Hono API removal**: Removes unused Hono methods (`route`, `mount`, `fire`) that are only used during application initialization

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

**Arguments:**

- `entry` - Entry file for your Hono app (TypeScript/JSX supported, optional)
Expand All @@ -119,31 +120,62 @@ hono optimize [entry] [options]
- `-o, --outfile <outfile>` - Output file
- `-m, --minify` - minify output file
- `-t, --target [target]` - environment target
- `--optimize` - apply Hono-specific optimizations
- `--no-request-body-api-removal` - Disable request body API removal optimization
- `--no-context-response-api-removal` - Disable response utility API removal from Context object
- `--no-hono-api-removal` - Disable Hono API removal optimization
- `--plain` - human-readable output instead of JSON

**Examples:**

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

# Build with optimizations
hono build --optimize

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

# Build with minification
hono build -m --optimize
```

**Output:**

# Export bundled file with minification
hono optimize -m
The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` on success, `ok: false` and `error` (with `code`, `message`, and `hint`) on failure with exit code 1.

# Specify environment target
hono optimize -t es2024
```json
{
"ok": true,
"data": {
"optimized": true,
"router": "PreparedRegExpRouter",
"removed": {
"requestBodyApis": true,
"contextResponseApis": ["body", "json", "html", "redirect"],
"honoApis": ["route", "mount", "fire"]
},
"output": "dist/index.js",
"size": 34124
}
}
```

# Disable specific optimizations
hono optimize -m --no-request-body-api-removal
hono optimize -m --no-context-response-api-removal
hono optimize -m --no-hono-api-removal
```json
{
"ok": false,
"error": {
"code": "ENTRY_NOT_FOUND",
"message": "Entry file missing.ts does not exist",
"hint": "Pass an existing entry file: hono build src/index.ts"
}
}
```

Use `--plain` for a human-readable format.

## Tips

### Using Hono CLI with AI Code Agents
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { optimizeCommand } from './commands/optimize/index.js'
import { buildCommand } from './commands/build/index.js'
import { requestCommand } from './commands/request/index.js'

const __filename = fileURLToPath(import.meta.url)
Expand All @@ -19,7 +19,7 @@ program
.version(packageJson.version, '-v, --version', 'display version number')

// Register commands
optimizeCommand(program)
buildCommand(program)
requestCommand(program)

program.parse()
136 changes: 113 additions & 23 deletions src/commands/optimize/index.test.ts → src/commands/build/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Command } from 'commander'
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } 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'
import { buildCommand } from './index'

const program = new Command()
optimizeCommand(program)
buildCommand(program)

const writePackageJSON = (dir: string, honoVersion: string = 'latest') => {
writeFileSync(
Expand All @@ -28,18 +28,24 @@ const npmInstall = async () =>
})
})

describe('optimizeCommand', () => {
describe('buildCommand', () => {
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('should print a JSON error if entry file not found', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await program.parseAsync(['node', 'hono', 'build', '--optimize', './non-existent-file.ts'])
const parsed = JSON.parse(log.mock.calls[0][0] as string)
expect(parsed.ok).toBe(false)
expect(parsed.error.code).toBe('ENTRY_NOT_FOUND')
expect(parsed.error.hint).toBeDefined()
expect(process.exitCode).toBe(1)
process.exitCode = undefined
log.mockRestore()
})

it.each([
Expand Down Expand Up @@ -218,7 +224,7 @@ describe('optimizeCommand', () => {
for (const file of files) {
writeFileSync(join(dir, file.path), file.content)
}
await program.parseAsync(['node', 'hono', 'optimize', ...(args ?? [])])
await program.parseAsync(['node', 'hono', 'build', '--optimize', ...(args ?? [])])

const content = readFileSync(join(dir, result.path), 'utf-8')
if (result.lineCount) {
Expand Down Expand Up @@ -259,11 +265,11 @@ describe('optimizeCommand', () => {
`
)

const promise = program.parseAsync(['node', 'hono', 'optimize', '-t', target])
const promise = program.parseAsync(['node', 'hono', 'build', '--optimize', '-t', target])
await expect(promise).resolves.not.toThrow()
})

it('should throw an error with invalid environment target', async () => {
it('should print a JSON error with invalid environment target', async () => {
writePackageJSON(dir)
await npmInstall()
writeFileSync(
Expand All @@ -276,8 +282,14 @@ describe('optimizeCommand', () => {
`
)

const promise = program.parseAsync(['node', 'hono', 'optimize', '-t', 'hoge'])
await expect(promise).rejects.toThrowError()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await program.parseAsync(['node', 'hono', 'build', '--optimize', '-t', 'hoge'])
const parsed = JSON.parse(log.mock.calls[0][0] as string)
expect(parsed.ok).toBe(false)
expect(parsed.error.code).toBe('UNEXPECTED_ERROR')
expect(process.exitCode).toBe(1)
process.exitCode = undefined
log.mockRestore()
})

describe('request body API removal', () => {
Expand All @@ -297,7 +309,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize'])
await program.parseAsync(['node', 'hono', 'build', '--optimize'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
expect(content).not.toMatch(/parseBody/)
Expand All @@ -321,7 +333,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize'])
await program.parseAsync(['node', 'hono', 'build', '--optimize'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
expect(content).toMatch(/parseBody/)
Expand All @@ -342,7 +354,13 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '--no-request-body-api-removal'])
await program.parseAsync([
'node',
'hono',
'build',
'--optimize',
'--no-request-body-api-removal',
])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
expect(content).toMatch(/parseBody/)
Expand All @@ -363,7 +381,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '-m'])
await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// These methods should be removed when unused
Expand All @@ -384,7 +402,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '-m'])
await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// route method should be kept when used
Expand All @@ -403,7 +421,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '-m'])
await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// mount method should be kept when used
Expand All @@ -425,7 +443,14 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '-m', '--no-hono-api-removal'])
await program.parseAsync([
'node',
'hono',
'build',
'--optimize',
'-m',
'--no-hono-api-removal',
])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// fire method should be kept when --no-hono-api-removal is specified
Expand All @@ -447,7 +472,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize'])
await program.parseAsync(['node', 'hono', 'build', '--optimize'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// Unused response methods should be removed (json, html, redirect)
Expand All @@ -469,7 +494,7 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize'])
await program.parseAsync(['node', 'hono', 'build', '--optimize'])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// Used response methods should be kept
Expand All @@ -493,7 +518,13 @@ describe('optimizeCommand', () => {
export default app
`
)
await program.parseAsync(['node', 'hono', 'optimize', '--no-context-response-api-removal'])
await program.parseAsync([
'node',
'hono',
'build',
'--optimize',
'--no-context-response-api-removal',
])

const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
// All response methods should be kept when --no-context-response-api-removal is specified
Expand All @@ -503,4 +534,63 @@ describe('optimizeCommand', () => {
}
)
})

describe('output format', () => {
const writeApp = () => {
writeFileSync(
join(dir, './src/index.ts'),
`
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello'))
export default app
`
)
}

it('should build without optimizations and print JSON', { timeout: 0 }, async () => {
writePackageJSON(dir)
await npmInstall()
writeApp()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await program.parseAsync(['node', 'hono', 'build'])
const parsed = JSON.parse(log.mock.calls[0][0] as string)
expect(parsed.ok).toBe(true)
expect(parsed.data.optimized).toBe(false)
expect(parsed.data.router).toBeUndefined()
expect(parsed.data.output).toBe('dist/index.js')
expect(parsed.data.size).toBeGreaterThan(0)
const content = readFileSync(join(dir, './dist/index.js'), 'utf-8')
expect(content).not.toContain('PreparedRegExpRouter')
log.mockRestore()
})

it('should print optimization details as JSON with --optimize', { timeout: 0 }, async () => {
writePackageJSON(dir)
await npmInstall()
writeApp()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await program.parseAsync(['node', 'hono', 'build', '--optimize'])
const parsed = JSON.parse(log.mock.calls[0][0] as string)
expect(parsed.ok).toBe(true)
expect(parsed.data.optimized).toBe(true)
expect(parsed.data.router).toBeDefined()
expect(parsed.data.removed.requestBodyApis).toBe(true)
expect(parsed.data.size).toBeGreaterThan(0)
log.mockRestore()
})

it('should print text with --plain', { timeout: 0 }, async () => {
writePackageJSON(dir)
await npmInstall()
writeApp()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await program.parseAsync(['node', 'hono', 'build', '--optimize', '--plain'])
const output = log.mock.calls[0][0] as string
expect(output).toContain('[Build]')
expect(output).toContain('Router:')
expect(output).toContain('Output: dist/index.js')
log.mockRestore()
})
})
})
Loading
Loading