diff --git a/README.md b/README.md index c20b250..4e9b076 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ hono --help # Send request to Hono app hono request -# Build your Hono app -hono build +# Build an optimized Hono app +hono optimize # Show routes of your Hono app hono routes @@ -43,7 +43,7 @@ hono agent-context ## Commands - `request [file]` - Send request to Hono app using `app.request()` -- `build [entry]` - Build your Hono app +- `optimize [entry]` - Build an optimized Hono app - `routes [file]` - Show routes of your Hono app - `ssg [file]` - Generate static files from your Hono app - `agent-context` - Show how to use Hono CLI, for coding agents @@ -118,15 +118,15 @@ The result is JSON with the shared envelope. A JSON response body is embedded as A binary response body becomes `"body": null` with `"binary": true` — save it with `-o`. Use `--plain` to print the raw body like curl. -### `build` +### `optimize` -Build your Hono app into a single bundled file. +Build your Hono app into a single optimized bundle. For a plain bundle, use your normal build tool — this command exists for the Hono-specific optimizations: ```bash -hono build [entry] [options] +hono optimize [entry] [options] ``` -With the `--optimize` option, it also applies Hono-specific optimizations to reduce bundle size: +It applies the following 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 every route method is strictly GET, HEAD, or OPTIONS. A route or middleware registered with `all()` or `use()` keeps the APIs, because it may read the request body @@ -142,7 +142,6 @@ With the `--optimize` option, it also applies Hono-specific optimizations to red - `-o, --outfile ` - Output file - `-m, --minify` - minify output file - `-t, --target [target]` - environment target -- `--optimize` - apply Hono-specific optimizations - `--request-body-api-removal ` - Request body API removal mode: `auto` (default), `force`, or `disable` - `--no-context-response-api-removal` - Disable response utility API removal from Context object - `--no-hono-api-removal` - Disable Hono API removal optimization @@ -151,17 +150,17 @@ With the `--optimize` option, it also applies Hono-specific optimizations to red **Examples:** ```bash -# Build src/index.ts to dist/index.js -hono build - -# Build with optimizations -hono build --optimize +# Build an optimized bundle to dist/index.js +hono optimize # Specify entry file and output file -hono build -o dist/app.js src/app.ts +hono optimize -o dist/app.js src/app.ts + +# With minification +hono optimize -m -# Build with minification -hono build -m --optimize +# Control request body API removal +hono optimize --request-body-api-removal force ``` **Output:** @@ -172,7 +171,6 @@ The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` { "ok": true, "data": { - "optimized": true, "router": "PreparedRegExpRouter", "removed": { "requestBodyApis": true, @@ -192,7 +190,7 @@ The result is JSON. All Hono CLI commands use the same envelope: `ok` and `data` "code": "ENTRY_NOT_FOUND", "message": "Entry file missing.ts does not exist", "suggestions": [ - "Pass the entry file: hono build src/app.ts", + "Pass the entry file: hono optimize src/app.ts", "Default candidates are src/index.ts, src/index.tsx, src/index.js, and src/index.jsx" ] } diff --git a/src/cli.ts b/src/cli.ts index 6d2d32e..9b5163f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { agentContextCommand } from './commands/agent-context/index.js' -import { buildCommand } from './commands/build/index.js' +import { optimizeCommand } from './commands/optimize/index.js' import { requestCommand } from './commands/request/index.js' import { routesCommand } from './commands/routes/index.js' import { ssgCommand } from './commands/ssg/index.js' @@ -23,7 +23,7 @@ program .addHelpText('after', "\nFor coding agents: run 'hono agent-context' and follow it.") // Register commands -buildCommand(program) +optimizeCommand(program) requestCommand(program) routesCommand(program) ssgCommand(program) diff --git a/src/commands/agent-context/document.ts b/src/commands/agent-context/document.ts index df2e0fb..cc00c92 100644 --- a/src/commands/agent-context/document.ts +++ b/src/commands/agent-context/document.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander' import type { CommandAgentContext } from '../../utils/agent-context.js' import { bullets, codeBlock, section, steps } from '../../utils/markdown.js' -import { agentContext as buildContext } from '../build/index.js' +import { agentContext as optimizeContext } from '../optimize/index.js' import { agentContext as requestContext } from '../request/index.js' import { agentContext as routesContext } from '../routes/index.js' import { agentContext as ssgContext } from '../ssg/index.js' @@ -9,7 +9,7 @@ import { agentContext as ssgContext } from '../ssg/index.js' const contexts: Record = { routes: routesContext, request: requestContext, - build: buildContext, + optimize: optimizeContext, ssg: ssgContext, } @@ -70,7 +70,7 @@ export const renderAgentContext = (program: Command): string => '`hono routes` — get all routes of the app without reading the source', '`hono request -P ` — send a request to the app without a server', 'After you change the app, run them again to verify', - '`hono build` — bundle the app (`--optimize` to reduce size)', + '`hono optimize` — build an optimized bundle of the app', ]) ), section( diff --git a/src/commands/agent-context/index.test.ts b/src/commands/agent-context/index.test.ts index a6e48b9..b473ff9 100644 --- a/src/commands/agent-context/index.test.ts +++ b/src/commands/agent-context/index.test.ts @@ -1,6 +1,6 @@ import { Command } from 'commander' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { buildCommand } from '../build/index.js' +import { optimizeCommand } from '../optimize/index.js' import { requestCommand } from '../request/index.js' import { routesCommand } from '../routes/index.js' import { agentContextCommand } from './index.js' @@ -12,7 +12,7 @@ describe('agentContextCommand', () => { beforeEach(() => { program = new Command() - buildCommand(program) + optimizeCommand(program) requestCommand(program) routesCommand(program) agentContextCommand(program) @@ -45,7 +45,7 @@ describe('agentContextCommand', () => { it('should document every command except itself', async () => { const output = await getOutput() - expect(output).toContain('### hono build [entry]') + expect(output).toContain('### hono optimize [entry]') expect(output).toContain('### hono request [file]') expect(output).toContain('### hono routes [file]') expect(output).not.toContain('### hono agent-context') @@ -53,7 +53,7 @@ describe('agentContextCommand', () => { it('should include options from the command definitions', async () => { const output = await getOutput() - expect(output).toContain('`--optimize`') + expect(output).toContain('`--request-body-api-removal `') expect(output).toContain('`-P, --path `') expect(output).toContain('`--verbose`') }) @@ -63,6 +63,6 @@ describe('agentContextCommand', () => { expect(output).toContain('`ENTRY_NOT_FOUND`') expect(output).toContain('`INVALID_APP`') expect(output).toContain('"router": "SmartRouter + RegExpRouter"') - expect(output).toContain('hono build --optimize') + expect(output).toContain('hono optimize -m') }) }) diff --git a/src/commands/build/index.test.ts b/src/commands/optimize/index.test.ts similarity index 86% rename from src/commands/build/index.test.ts rename to src/commands/optimize/index.test.ts index b824765..9fa35d9 100644 --- a/src/commands/build/index.test.ts +++ b/src/commands/optimize/index.test.ts @@ -4,10 +4,10 @@ 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 { buildCommand } from './index' +import { optimizeCommand } from './index' const program = new Command() -buildCommand(program) +optimizeCommand(program) const writePackageJSON = (dir: string, honoVersion: string = 'latest') => { writeFileSync( @@ -28,7 +28,7 @@ const npmInstall = async () => }) }) -describe('buildCommand', () => { +describe('optimizeCommand', () => { let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'hono-cli-optimize-test')) @@ -38,7 +38,7 @@ describe('buildCommand', () => { 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']) + await program.parseAsync(['node', 'hono', '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') @@ -224,7 +224,7 @@ describe('buildCommand', () => { for (const file of files) { writeFileSync(join(dir, file.path), file.content) } - await program.parseAsync(['node', 'hono', 'build', '--optimize', ...(args ?? [])]) + await program.parseAsync(['node', 'hono', 'optimize', ...(args ?? [])]) const content = readFileSync(join(dir, result.path), 'utf-8') if (result.lineCount) { @@ -265,7 +265,7 @@ describe('buildCommand', () => { ` ) - const promise = program.parseAsync(['node', 'hono', 'build', '--optimize', '-t', target]) + const promise = program.parseAsync(['node', 'hono', 'optimize', '-t', target]) await expect(promise).resolves.not.toThrow() }) @@ -283,7 +283,7 @@ describe('buildCommand', () => { ) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'hono', 'build', '--optimize', '-t', 'hoge']) + await program.parseAsync(['node', 'hono', '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') @@ -309,7 +309,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') expect(content).not.toMatch(/parseBody/) @@ -333,7 +333,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') expect(content).toMatch(/parseBody/) @@ -355,7 +355,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') expect(content).toMatch(/parseBody/) @@ -380,7 +380,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') expect(content).toMatch(/parseBody/) @@ -404,8 +404,7 @@ describe('buildCommand', () => { await program.parseAsync([ 'node', 'hono', - 'build', - '--optimize', + 'optimize', '--request-body-api-removal', 'disable', ]) @@ -436,8 +435,7 @@ describe('buildCommand', () => { await program.parseAsync([ 'node', 'hono', - 'build', - '--optimize', + 'optimize', '--request-body-api-removal', 'force', ]) @@ -450,14 +448,7 @@ describe('buildCommand', () => { it('should print a JSON error with an invalid mode', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync([ - 'node', - 'hono', - 'build', - '--optimize', - '--request-body-api-removal', - 'hoge', - ]) + await program.parseAsync(['node', 'hono', 'optimize', '--request-body-api-removal', 'hoge']) const parsed = JSON.parse(log.mock.calls[0][0] as string) expect(parsed.ok).toBe(false) expect(parsed.error.code).toBe('INVALID_OPTION') @@ -480,7 +471,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m']) + await program.parseAsync(['node', 'hono', 'optimize', '-m']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') // These methods should be removed when unused @@ -501,7 +492,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m']) + await program.parseAsync(['node', 'hono', 'optimize', '-m']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') // route method should be kept when used @@ -520,7 +511,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize', '-m']) + await program.parseAsync(['node', 'hono', 'optimize', '-m']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') // mount method should be kept when used @@ -542,14 +533,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync([ - 'node', - 'hono', - 'build', - '--optimize', - '-m', - '--no-hono-api-removal', - ]) + await program.parseAsync(['node', 'hono', '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 @@ -571,7 +555,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') // Unused response methods should be removed (json, html, redirect) @@ -593,7 +577,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', 'optimize']) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') // Used response methods should be kept @@ -617,13 +601,7 @@ describe('buildCommand', () => { export default app ` ) - await program.parseAsync([ - 'node', - 'hono', - 'build', - '--optimize', - '--no-context-response-api-removal', - ]) + await program.parseAsync(['node', 'hono', '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 @@ -647,32 +625,14 @@ describe('buildCommand', () => { ) } - 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 () => { + it('should print optimization details as JSON', { timeout: 0 }, async () => { writePackageJSON(dir) await npmInstall() writeApp() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'hono', 'build', '--optimize']) + await program.parseAsync(['node', 'hono', '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) @@ -684,9 +644,9 @@ describe('buildCommand', () => { await npmInstall() writeApp() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'hono', 'build', '--optimize', '--plain']) + await program.parseAsync(['node', 'hono', 'optimize', '--plain']) const output = log.mock.calls[0][0] as string - expect(output).toContain('[Build]') + expect(output).toContain('[Optimized]') expect(output).toContain('Router:') expect(output).toContain('Output: dist/index.js') log.mockRestore() diff --git a/src/commands/build/index.ts b/src/commands/optimize/index.ts similarity index 84% rename from src/commands/build/index.ts rename to src/commands/optimize/index.ts index 879bda9..c138030 100644 --- a/src/commands/build/index.ts +++ b/src/commands/optimize/index.ts @@ -12,12 +12,12 @@ import { removeApis } from './remove-apis.js' export const agentContext: CommandAgentContext = { output: - '{ "optimized": true, "router": "PreparedRegExpRouter", "removed": { "requestBodyApis": true, "contextResponseApis": ["html"], "honoApis": ["route"] }, "output": "dist/index.js", "size": 34124 }', + '{ "router": "PreparedRegExpRouter", "removed": { "requestBodyApis": true, "contextResponseApis": ["html"], "honoApis": ["route"] }, "output": "dist/index.js", "size": 34124 }', errors: ['ENTRY_NOT_FOUND', 'INVALID_OPTION'], - examples: ['hono build', 'hono build --optimize', 'hono build --optimize -m -o dist/app.js'], + examples: ['hono optimize', 'hono optimize -m -o dist/app.js'], notes: [ - 'Without --optimize it just bundles the app.', - 'With --optimize, request body APIs are removed only when every route method is strictly GET/HEAD/OPTIONS. If you have checked the app never reads request bodies, pass --request-body-api-removal force.', + 'For a plain bundle, use your normal build tool. This command exists for the Hono-specific optimizations.', + 'Request body APIs are removed only when every route method is strictly GET/HEAD/OPTIONS. If you have checked the app never reads request bodies, pass --request-body-api-removal force.', ], } @@ -36,21 +36,19 @@ const REQUEST_BODY_METHODS = [ ] const CONTEXT_RESPONSE_METHODS = ['body', 'json', 'text', 'html', 'redirect'] -interface BuildOptions { +interface OptimizeOptions { outfile: string minify?: boolean target: string - optimize?: boolean requestBodyApiRemoval: 'auto' | 'force' | 'disable' honoApiRemoval: boolean contextResponseApiRemoval: boolean plain?: boolean } -interface BuildResult { - optimized: boolean - router?: string - removed?: { +interface OptimizeResult { + router: string + removed: { requestBodyApis: boolean contextResponseApis: string[] honoApis: string[] @@ -59,14 +57,13 @@ interface BuildResult { size: number } -export function buildCommand(program: Command) { +export function optimizeCommand(program: Command) { program - .command('build') - .description('Build your Hono app') + .command('optimize') + .description('Build an optimized Hono app') .argument('[entry]', 'entry file') .option('-o, --outfile [outfile]', 'output file', 'dist/index.js') .option('-m, --minify', 'minify output file') - .option('--optimize', 'apply Hono-specific optimizations') .option( '--request-body-api-removal ', 'request body API removal mode (auto | force | disable)', @@ -80,7 +77,7 @@ export function buildCommand(program: Command) { .option('-t, --target [target]', 'environment target (e.g., node24, deno2, es2024)', 'node20') .option('--plain', 'human-readable output instead of JSON') .action( - handleErrors(async (entry: string, options: BuildOptions) => { + handleErrors(async (entry: string, options: OptimizeOptions) => { if (!['auto', 'force', 'disable'].includes(options.requestBodyApiRemoval)) { throw new CliError( 'INVALID_OPTION', @@ -99,7 +96,7 @@ export function buildCommand(program: Command) { if (!existsSync(appPath)) { throw new CliError('ENTRY_NOT_FOUND', `Entry file ${entry} does not exist`, { suggestions: [ - 'Pass the entry file: hono build src/app.ts', + 'Pass the entry file: hono optimize src/app.ts', 'Default candidates are src/index.ts, src/index.tsx, src/index.js, and src/index.jsx', ], }) @@ -108,9 +105,7 @@ export function buildCommand(program: Command) { const appFilePath = realpathSync(appPath) const outfile = resolve(process.cwd(), options.outfile) - const result = options.optimize - ? await buildOptimized(appFilePath, outfile, options) - : await buildPlain(appFilePath, outfile, options) + const result = await buildOptimized(appFilePath, outfile, options) result.size = statSync(outfile).size if (options.plain) { @@ -122,54 +117,31 @@ export function buildCommand(program: Command) { ) } -const formatPlainResult = (result: BuildResult): string => { - const lines = ['[Build]'] - if (result.router) { - lines.push(` Router: ${result.router}`) +const formatPlainResult = (result: OptimizeResult): string => { + const lines = ['[Optimized]'] + lines.push(` Router: ${result.router}`) + const removed = [] + if (result.removed.requestBodyApis) { + removed.push('Request body APIs') } - if (result.removed) { - const removed = [] - if (result.removed.requestBodyApis) { - removed.push('Request body APIs') - } - if (result.removed.contextResponseApis.length > 0) { - removed.push(`Context response APIs (${result.removed.contextResponseApis.join(', ')})`) - } - if (result.removed.honoApis.length > 0) { - removed.push(`Hono APIs (${result.removed.honoApis.join(', ')})`) - } - if (removed.length > 0) { - lines.push(` Removed:\n${removed.map((r) => ` ${r}`).join('\n')}`) - } + if (result.removed.contextResponseApis.length > 0) { + removed.push(`Context response APIs (${result.removed.contextResponseApis.join(', ')})`) + } + if (result.removed.honoApis.length > 0) { + removed.push(`Hono APIs (${result.removed.honoApis.join(', ')})`) + } + if (removed.length > 0) { + lines.push(` Removed:\n${removed.map((r) => ` ${r}`).join('\n')}`) } lines.push(` Output: ${result.output} (${(result.size / 1024).toFixed(2)} KB)`) return lines.join('\n') } -const buildPlain = async ( - appFilePath: string, - outfile: string, - options: BuildOptions -): Promise => { - await esbuild.build({ - entryPoints: [appFilePath], - outfile, - bundle: true, - minify: options.minify, - format: 'esm', - target: options.target, - platform: 'node', - jsx: 'automatic', - jsxImportSource: 'hono/jsx', - }) - return { optimized: false, output: options.outfile, size: 0 } -} - const buildOptimized = async ( appFilePath: string, outfile: string, - options: BuildOptions -): Promise => { + options: OptimizeOptions +): Promise => { const unusedContextResponseMethods = new Set( options.contextResponseApiRemoval ? CONTEXT_RESPONSE_METHODS : [] ) @@ -445,7 +417,6 @@ export class Hono extends HonoBase { }) return { - optimized: true, router: routerName, removed: { requestBodyApis: removeRequestBodyApi, diff --git a/src/commands/build/remove-apis.test.ts b/src/commands/optimize/remove-apis.test.ts similarity index 100% rename from src/commands/build/remove-apis.test.ts rename to src/commands/optimize/remove-apis.test.ts diff --git a/src/commands/build/remove-apis.ts b/src/commands/optimize/remove-apis.ts similarity index 100% rename from src/commands/build/remove-apis.ts rename to src/commands/optimize/remove-apis.ts