From c662ead23d4a317c06ca50929afe23180fa82ba2 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 17 Aug 2026 17:47:23 +0900 Subject: [PATCH] feat: rename optimize to build with JSON output --- README.md | 74 ++- src/cli.ts | 4 +- .../{optimize => build}/index.test.ts | 136 +++++- src/commands/build/index.ts | 436 ++++++++++++++++++ .../{optimize => build}/remove-apis.test.ts | 0 .../{optimize => build}/remove-apis.ts | 0 src/commands/optimize/index.ts | 365 --------------- src/utils/output.test.ts | 64 +++ src/utils/output.ts | 49 ++ 9 files changed, 717 insertions(+), 411 deletions(-) rename src/commands/{optimize => build}/index.test.ts (74%) create mode 100644 src/commands/build/index.ts rename src/commands/{optimize => build}/remove-apis.test.ts (100%) rename src/commands/{optimize => build}/remove-apis.ts (100%) delete mode 100644 src/commands/optimize/index.ts create mode 100644 src/utils/output.test.ts create mode 100644 src/utils/output.ts diff --git a/README.md b/README.md index 85165eb..591f173 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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) @@ -119,31 +120,62 @@ hono optimize [entry] [options] - `-o, --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 diff --git a/src/cli.ts b/src/cli.ts index 3721090..2c72ea4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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) @@ -19,7 +19,7 @@ program .version(packageJson.version, '-v, --version', 'display version number') // Register commands -optimizeCommand(program) +buildCommand(program) requestCommand(program) program.parse() diff --git a/src/commands/optimize/index.test.ts b/src/commands/build/index.test.ts similarity index 74% rename from src/commands/optimize/index.test.ts rename to src/commands/build/index.test.ts index 973171f..44fd9f4 100644 --- a/src/commands/optimize/index.test.ts +++ b/src/commands/build/index.test.ts @@ -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( @@ -28,7 +28,7 @@ const npmInstall = async () => }) }) -describe('optimizeCommand', () => { +describe('buildCommand', () => { let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'hono-cli-optimize-test')) @@ -36,10 +36,16 @@ describe('optimizeCommand', () => { 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([ @@ -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) { @@ -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( @@ -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', () => { @@ -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/) @@ -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/) @@ -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/) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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() + }) + }) }) diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts new file mode 100644 index 0000000..4aea4fc --- /dev/null +++ b/src/commands/build/index.ts @@ -0,0 +1,436 @@ +import type { Command } from 'commander' +import * as esbuild from 'esbuild' +import type { Hono } from 'hono' +import { METHOD_NAME_ALL } from 'hono/router' +import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router' +import { execFile } from 'node:child_process' +import { existsSync, realpathSync, statSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { buildAndImportApp } from '../../utils/build.js' +import { CliError, handleErrors, printResult } from '../../utils/output.js' +import { removeApis } from './remove-apis.js' + +const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] + +const HONO_REMOVAL_METHODS = ['route', 'mount', 'fire'] +const REQUEST_BODY_METHODS = [ + 'parseBody', + 'json', + 'text', + 'arrayBuffer', + 'bytes', + 'blob', + 'formData', + '#cachedBody', +] +const CONTEXT_RESPONSE_METHODS = ['body', 'json', 'text', 'html', 'redirect'] + +interface BuildOptions { + outfile: string + minify?: boolean + target: string + optimize?: boolean + requestBodyApiRemoval: boolean + honoApiRemoval: boolean + contextResponseApiRemoval: boolean + plain?: boolean +} + +interface BuildResult { + optimized: boolean + router?: string + removed?: { + requestBodyApis: boolean + contextResponseApis: string[] + honoApis: string[] + } + output: string + size: number +} + +export function buildCommand(program: Command) { + program + .command('build') + .description('Build your 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( + '--no-request-body-api-removal', + 'do not remove request body APIs even if they are not needed' + ) + .option('--no-hono-api-removal', 'do not remove Hono APIs even if they are not used') + .option( + '--no-context-response-api-removal', + 'do not remove response utility APIs from Context object' + ) + .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) => { + 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 CliError( + 'ENTRY_NOT_FOUND', + `Entry file ${entry} does not exist`, + 'Pass an existing entry file: hono build src/index.ts' + ) + } + + 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) + result.size = statSync(outfile).size + + if (options.plain) { + console.log(formatPlainResult(result)) + } else { + printResult(result) + } + }) + ) +} + +const formatPlainResult = (result: BuildResult): string => { + const lines = ['[Build]'] + if (result.router) { + lines.push(` Router: ${result.router}`) + } + 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')}`) + } + } + 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 => { + const unusedContextResponseMethods = new Set( + options.contextResponseApiRemoval ? CONTEXT_RESPONSE_METHODS : [] + ) + const contextResponseMethodsRegExp = new RegExp( + `(?<=\\.)${[...unusedContextResponseMethods].join('|')}(?=\\()`, + 'g' + ) + + const buildIterator = buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + 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' +import { TrieRouter } from 'hono/router/trie-router' + +export class Hono extends HonoBase { + constructor(options = {}) { + super(options) + this.router = options.router ?? new TrieRouter() + } + + unusedMethods = ${JSON.stringify( + HONO_REMOVAL_METHODS.reduce( + (acc, method) => { + acc[method] = 1 + return acc + }, + {} as Record + ) + )} + ${HONO_REMOVAL_METHODS.map( + (method) => `get ${method}() { + delete this.unusedMethods["${method}"] + return super.${method} + }` + ).join('\n')} +} +`, + } + }) + + build.onLoad({ filter: /\.(?:jsx?|tsx?)/ }, async ({ path }) => { + if (!path.match(/node_modules(\/|\\)hono(\/|\\)dist/)) { + ;(readFileSync(path, 'utf8').match(contextResponseMethodsRegExp) || []).forEach( + (m) => { + unusedContextResponseMethods.delete(m) + } + ) + } + return undefined + }) + }, + }, + ], + }) + const app: Hono = (await buildIterator.next()).value + + let routerName + let importStatement + let assignRouterStatement + try { + const serialized = serializeInitParams( + buildInitParams({ + paths: app.routes.map(({ path }) => path), + }) + ) + + const hasPreparedRegExpRouter = await new Promise((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) { + routerName = 'PreparedRegExpRouter' + importStatement = "import { PreparedRegExpRouter } from 'hono/router/reg-exp-router'" + assignRouterStatement = `const routerParams = ${serialized} + this.router = new PreparedRegExpRouter(...routerParams)` + } else { + routerName = 'RegExpRouter' + importStatement = "import { RegExpRouter } from 'hono/router/reg-exp-router'" + assignRouterStatement = 'this.router = new RegExpRouter()' + } + } catch { + // fallback to default router + routerName = 'TrieRouter' + importStatement = "import { TrieRouter } from 'hono/router/trie-router'" + assignRouterStatement = 'this.router = new TrieRouter()' + } + + const removeRequestBodyApi = + options.requestBodyApiRemoval !== false && + app.routes.every(({ method }) => + [METHOD_NAME_ALL, 'GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) + ) + const unusedHonoMethods: Record = ( + app as Hono & { unusedMethods: Record } + ).unusedMethods + const removeHonoApi = + options.honoApiRemoval !== false && Object.keys(unusedHonoMethods).length > 0 + + await esbuild.build({ + entryPoints: [appFilePath], + outfile, + bundle: true, + minify: options.minify, + format: 'esm', + target: options.target, + 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} + } +} +`, + } + }) + + if (removeRequestBodyApi) { + const honoRequestPseudoImportPath = 'hono-optimized-request-pseudo-import-path' + build.onResolve({ filter: /request\.js$/ }, async (args) => { + if (!args.importer) { + return undefined + } + + // resolve original import path for "request" + 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), honoRequestPseudoImportPath), + } + }) + build.onLoad( + { filter: new RegExp(`/${honoRequestPseudoImportPath}$`) }, + async (args) => { + let contents = readFileSync(join(dirname(args.path), 'request.js'), 'utf-8') + + contents = removeApis(contents, 'HonoRequest', REQUEST_BODY_METHODS) + return { + contents, + } + } + ) + } + + if (options.contextResponseApiRemoval) { + const honoRequestPseudoImportPath = 'hono-optimized-context-pseudo-import-path' + build.onResolve({ filter: /context\.js$/ }, async (args) => { + if (!args.importer) { + return undefined + } + + // resolve original import path for "context" + 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), honoRequestPseudoImportPath), + } + }) + build.onLoad( + { filter: new RegExp(`/${honoRequestPseudoImportPath}$`) }, + async (args) => { + let contents = readFileSync(join(dirname(args.path), 'context.js'), 'utf-8') + + contents = removeApis(contents, 'Context', [...unusedContextResponseMethods]) + return { + contents, + } + } + ) + } + + if (removeHonoApi) { + const honoPseudoImportPath = 'hono-base-optimized-pseudo-import-path' + build.onResolve({ filter: /hono-base\.js$|^hono\/hono-base$/ }, async (args) => { + if (!args.importer) { + return undefined + } + + // resolve original import path for "context" + 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 (args) => { + let contents = readFileSync(join(dirname(args.path), 'hono-base.js'), 'utf-8') + + contents = removeApis(contents, 'Hono', Object.keys(unusedHonoMethods)) + return { + contents, + } + }) + } + }, + }, + ], + }) + + return { + optimized: true, + router: routerName, + removed: { + requestBodyApis: removeRequestBodyApi, + contextResponseApis: [...unusedContextResponseMethods], + honoApis: removeHonoApi ? Object.keys(unusedHonoMethods) : [], + }, + output: options.outfile, + size: 0, + } +} diff --git a/src/commands/optimize/remove-apis.test.ts b/src/commands/build/remove-apis.test.ts similarity index 100% rename from src/commands/optimize/remove-apis.test.ts rename to src/commands/build/remove-apis.test.ts diff --git a/src/commands/optimize/remove-apis.ts b/src/commands/build/remove-apis.ts similarity index 100% rename from src/commands/optimize/remove-apis.ts rename to src/commands/build/remove-apis.ts diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts deleted file mode 100644 index 8a5ff97..0000000 --- a/src/commands/optimize/index.ts +++ /dev/null @@ -1,365 +0,0 @@ -import type { Command } from 'commander' -import * as esbuild from 'esbuild' -import type { Hono } from 'hono' -import { METHOD_NAME_ALL } from 'hono/router' -import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router' -import { execFile } from 'node:child_process' -import { existsSync, realpathSync, statSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { buildAndImportApp } from '../../utils/build.js' -import { removeApis } from './remove-apis.js' - -const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] - -const HONO_REMOVAL_METHODS = ['route', 'mount', 'fire'] -const REQUEST_BODY_METHODS = [ - 'parseBody', - 'json', - 'text', - 'arrayBuffer', - 'bytes', - 'blob', - 'formData', - '#cachedBody', -] -const CONTEXT_RESPONSE_METHODS = ['body', 'json', 'text', 'html', 'redirect'] - -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') - .option('-m, --minify', 'minify output file') - .option( - '--no-request-body-api-removal', - 'do not remove request body APIs even if they are not needed' - ) - .option('--no-hono-api-removal', 'do not remove Hono APIs even if they are not used') - .option( - '--no-context-response-api-removal', - 'do not remove response utility APIs from Context object' - ) - .option('-t, --target [target]', 'environment target (e.g., node24, deno2, es2024)', 'node20') - .action( - async ( - entry: string, - options: { - outfile: string - minify?: boolean - target: string - requestBodyApiRemoval: boolean - honoApiRemoval: boolean - contextResponseApiRemoval: boolean - } - ) => { - 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 unusedContextResponseMethods = new Set( - options.contextResponseApiRemoval ? CONTEXT_RESPONSE_METHODS : [] - ) - const contextResponseMethodsRegExp = new RegExp( - `(?<=\\.)${[...unusedContextResponseMethods].join('|')}(?=\\()`, - 'g' - ) - - const appFilePath = realpathSync(appPath) - const buildIterator = buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], - 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' -import { TrieRouter } from 'hono/router/trie-router' - -export class Hono extends HonoBase { - constructor(options = {}) { - super(options) - this.router = options.router ?? new TrieRouter() - } - - unusedMethods = ${JSON.stringify( - HONO_REMOVAL_METHODS.reduce( - (acc, method) => { - acc[method] = 1 - return acc - }, - {} as Record - ) - )} - ${HONO_REMOVAL_METHODS.map( - (method) => `get ${method}() { - delete this.unusedMethods["${method}"] - return super.${method} - }` - ).join('\n')} -} -`, - } - }) - - build.onLoad({ filter: /\.(?:jsx?|tsx?)/ }, async ({ path }) => { - if (!path.match(/node_modules(\/|\\)hono(\/|\\)dist/)) { - ;(readFileSync(path, 'utf8').match(contextResponseMethodsRegExp) || []).forEach( - (m) => { - unusedContextResponseMethods.delete(m) - } - ) - } - return undefined - }) - }, - }, - ], - }) - const app: Hono = (await buildIterator.next()).value - - let routerName - let importStatement - let assignRouterStatement - try { - const serialized = serializeInitParams( - buildInitParams({ - paths: app.routes.map(({ path }) => path), - }) - ) - - const hasPreparedRegExpRouter = await new Promise((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) { - routerName = 'PreparedRegExpRouter' - importStatement = "import { PreparedRegExpRouter } from 'hono/router/reg-exp-router'" - assignRouterStatement = `const routerParams = ${serialized} - this.router = new PreparedRegExpRouter(...routerParams)` - } else { - routerName = 'RegExpRouter' - importStatement = "import { RegExpRouter } from 'hono/router/reg-exp-router'" - assignRouterStatement = 'this.router = new RegExpRouter()' - } - } catch { - // fallback to default router - routerName = 'TrieRouter' - importStatement = "import { TrieRouter } from 'hono/router/trie-router'" - assignRouterStatement = 'this.router = new TrieRouter()' - } - - const removed = [] - const removeRequestBodyApi = - options.requestBodyApiRemoval !== false && - app.routes.every(({ method }) => - [METHOD_NAME_ALL, 'GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) - ) - const unusedHonoMethods: Record = ( - app as Hono & { unusedMethods: Record } - ).unusedMethods - const removeHonoApi = - options.honoApiRemoval !== false && Object.keys(unusedHonoMethods).length > 0 - - console.log('[Optimized]') - console.log(` Router: ${routerName}`) - if (removeRequestBodyApi) { - removed.push('Request body APIs') - } - if (unusedContextResponseMethods.size !== 0) { - removed.push(`Context response APIs (${[...unusedContextResponseMethods].join(', ')})`) - } - if (removeHonoApi) { - removed.push(`Hono APIs (${Object.keys(unusedHonoMethods).join(', ')})`) - } - if (removed.length > 0) { - console.log(` Removed:\n${removed.map((r) => ` ${r}`).join('\n')}`) - } - - const outfile = resolve(process.cwd(), options.outfile) - await esbuild.build({ - entryPoints: [appFilePath], - outfile, - bundle: true, - minify: options.minify, - format: 'esm', - target: options.target, - 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} - } -} -`, - } - }) - - if (removeRequestBodyApi) { - const honoRequestPseudoImportPath = 'hono-optimized-request-pseudo-import-path' - build.onResolve({ filter: /request\.js$/ }, async (args) => { - if (!args.importer) { - return undefined - } - - // resolve original import path for "request" - 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), honoRequestPseudoImportPath), - } - }) - build.onLoad( - { filter: new RegExp(`/${honoRequestPseudoImportPath}$`) }, - async (args) => { - let contents = readFileSync(join(dirname(args.path), 'request.js'), 'utf-8') - - contents = removeApis(contents, 'HonoRequest', REQUEST_BODY_METHODS) - return { - contents, - } - } - ) - } - - if (options.contextResponseApiRemoval) { - const honoRequestPseudoImportPath = 'hono-optimized-context-pseudo-import-path' - build.onResolve({ filter: /context\.js$/ }, async (args) => { - if (!args.importer) { - return undefined - } - - // resolve original import path for "context" - 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), honoRequestPseudoImportPath), - } - }) - build.onLoad( - { filter: new RegExp(`/${honoRequestPseudoImportPath}$`) }, - async (args) => { - let contents = readFileSync(join(dirname(args.path), 'context.js'), 'utf-8') - - contents = removeApis(contents, 'Context', [...unusedContextResponseMethods]) - return { - contents, - } - } - ) - } - - if (removeHonoApi) { - const honoPseudoImportPath = 'hono-base-optimized-pseudo-import-path' - build.onResolve({ filter: /hono-base\.js$|^hono\/hono-base$/ }, async (args) => { - if (!args.importer) { - return undefined - } - - // resolve original import path for "context" - 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 (args) => { - let contents = readFileSync(join(dirname(args.path), 'hono-base.js'), 'utf-8') - - contents = removeApis(contents, 'Hono', Object.keys(unusedHonoMethods)) - return { - contents, - } - } - ) - } - }, - }, - ], - }) - - const outfileStat = statSync(outfile) - console.log(` Output: ${options.outfile} (${(outfileStat.size / 1024).toFixed(2)} KB)`) - } - ) -} diff --git a/src/utils/output.test.ts b/src/utils/output.test.ts new file mode 100644 index 0000000..657e6bd --- /dev/null +++ b/src/utils/output.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { CliError, formatResult, formatError, handleErrors } from './output' + +describe('formatResult', () => { + it('should wrap data in the envelope', () => { + const parsed = JSON.parse(formatResult({ router: 'TrieRouter' })) + expect(parsed).toEqual({ ok: true, data: { router: 'TrieRouter' } }) + }) +}) + +describe('formatError', () => { + it('should include code, message, and hint', () => { + const error = new CliError('ENTRY_NOT_FOUND', 'src/index.ts does not exist', 'Pass a file') + expect(JSON.parse(formatError(error))).toEqual({ + ok: false, + error: { + code: 'ENTRY_NOT_FOUND', + message: 'src/index.ts does not exist', + hint: 'Pass a file', + }, + }) + }) + + it('should omit hint when not set', () => { + const error = new CliError('UNEXPECTED_ERROR', 'boom') + expect(JSON.parse(formatError(error))).toEqual({ + ok: false, + error: { code: 'UNEXPECTED_ERROR', message: 'boom' }, + }) + }) +}) + +describe('handleErrors', () => { + afterEach(() => { + process.exitCode = undefined + vi.restoreAllMocks() + }) + + it('should print a thrown CliError as JSON and set exit code 1', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await handleErrors(async () => { + throw new CliError('ENTRY_NOT_FOUND', 'not found') + })() + expect(JSON.parse(log.mock.calls[0][0]).error.code).toBe('ENTRY_NOT_FOUND') + expect(process.exitCode).toBe(1) + }) + + it('should convert an unknown error to UNEXPECTED_ERROR', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await handleErrors(async () => { + throw new Error('boom') + })() + const parsed = JSON.parse(log.mock.calls[0][0]) + expect(parsed.error).toEqual({ code: 'UNEXPECTED_ERROR', message: 'boom' }) + expect(process.exitCode).toBe(1) + }) + + it('should do nothing on success', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await handleErrors(async () => {})() + expect(log).not.toHaveBeenCalled() + expect(process.exitCode).toBeUndefined() + }) +}) diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..cb5ed89 --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,49 @@ +export class CliError extends Error { + code: string + hint?: string + + constructor(code: string, message: string, hint?: string) { + super(message) + this.code = code + this.hint = hint + } +} + +export const formatResult = (data: unknown): string => JSON.stringify({ ok: true, data }, null, 2) + +export const formatError = (error: CliError): string => + JSON.stringify( + { + ok: false, + error: { + code: error.code, + message: error.message, + ...(error.hint ? { hint: error.hint } : {}), + }, + }, + null, + 2 + ) + +export const printResult = (data: unknown): void => { + console.log(formatResult(data)) +} + +/** + * Wrap a command action. A thrown `CliError` (or any other error) + * becomes a JSON error on stdout with exit code 1. + */ +export const handleErrors = + (fn: (...args: A) => Promise) => + async (...args: A): Promise => { + try { + await fn(...args) + } catch (e) { + const error = + e instanceof CliError + ? e + : new CliError('UNEXPECTED_ERROR', e instanceof Error ? e.message : String(e)) + console.log(formatError(error)) + process.exitCode = 1 + } + }