diff --git a/README.md b/README.md index 591f173..083cb3b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ 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 +- **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 - **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 @@ -121,7 +121,7 @@ With the `--optimize` option, it also applies Hono-specific optimizations to red - `-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 +- `--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 - `--plain` - human-readable output instead of JSON diff --git a/src/commands/build/index.test.ts b/src/commands/build/index.test.ts index 44fd9f4..9b3f32e 100644 --- a/src/commands/build/index.test.ts +++ b/src/commands/build/index.test.ts @@ -339,8 +339,55 @@ describe('buildCommand', () => { expect(content).toMatch(/parseBody/) }) + it('should keep request body APIs when all method is used', { timeout: 0 }, async () => { + writePackageJSON(dir) + await npmInstall() + writeFileSync( + join(dir, './src/index.ts'), + ` + import { Hono } from 'hono' + const app = new Hono() + app.get('/', (c) => c.text('Hello')) + app.all('/data', async (c) => { + const body = await c.req.json() + return c.json(body) + }) + export default app + ` + ) + await program.parseAsync(['node', 'hono', 'build', '--optimize']) + + const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') + expect(content).toMatch(/parseBody/) + }) + + it('should keep request body APIs when middleware is used', { timeout: 0 }, async () => { + writePackageJSON(dir) + await npmInstall() + writeFileSync( + join(dir, './src/index.ts'), + ` + import { Hono } from 'hono' + const app = new Hono() + app.use(async (c, next) => { + if (c.req.method === 'POST') { + const body = await c.req.json() + c.set('body', body) + } + await next() + }) + app.get('/', (c) => c.text('Hello')) + export default app + ` + ) + await program.parseAsync(['node', 'hono', 'build', '--optimize']) + + const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') + expect(content).toMatch(/parseBody/) + }) + it( - 'should keep request body APIs when --no-request-body-api-removal is specified', + 'should keep request body APIs when --request-body-api-removal disable is specified', { timeout: 0 }, async () => { writePackageJSON(dir) @@ -359,13 +406,65 @@ describe('buildCommand', () => { 'hono', 'build', '--optimize', - '--no-request-body-api-removal', + '--request-body-api-removal', + 'disable', ]) const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') expect(content).toMatch(/parseBody/) } ) + + it( + 'should remove request body APIs when --request-body-api-removal force is specified', + { timeout: 0 }, + async () => { + writePackageJSON(dir) + await npmInstall() + writeFileSync( + join(dir, './src/index.ts'), + ` + import { Hono } from 'hono' + const app = new Hono() + app.post('/data', async (c) => { + const body = await c.req.json() + return c.json(body) + }) + export default app + ` + ) + await program.parseAsync([ + 'node', + 'hono', + 'build', + '--optimize', + '--request-body-api-removal', + 'force', + ]) + + const content = readFileSync(join(dir, './dist/index.js'), 'utf-8') + expect(content).not.toMatch(/parseBody/) + expect(content).not.toMatch(/#cachedBody/) + } + ) + + 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', + ]) + const parsed = JSON.parse(log.mock.calls[0][0] as string) + expect(parsed.ok).toBe(false) + expect(parsed.error.code).toBe('INVALID_OPTION') + expect(process.exitCode).toBe(1) + process.exitCode = undefined + log.mockRestore() + }) }) describe('Hono API removal', () => { diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index 4aea4fc..d67f9f3 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -1,7 +1,6 @@ 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' @@ -30,7 +29,7 @@ interface BuildOptions { minify?: boolean target: string optimize?: boolean - requestBodyApiRemoval: boolean + requestBodyApiRemoval: 'auto' | 'force' | 'disable' honoApiRemoval: boolean contextResponseApiRemoval: boolean plain?: boolean @@ -57,8 +56,9 @@ export function buildCommand(program: Command) { .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' + '--request-body-api-removal ', + 'request body API removal mode (auto | force | disable)', + 'auto' ) .option('--no-hono-api-removal', 'do not remove Hono APIs even if they are not used') .option( @@ -69,6 +69,13 @@ export function buildCommand(program: Command) { .option('--plain', 'human-readable output instead of JSON') .action( handleErrors(async (entry: string, options: BuildOptions) => { + if (!['auto', 'force', 'disable'].includes(options.requestBodyApiRemoval)) { + throw new CliError( + 'INVALID_OPTION', + `Invalid mode for --request-body-api-removal: ${options.requestBodyApiRemoval}`, + 'Use one of: auto, force, disable' + ) + } if (!entry) { entry = DEFAULT_ENTRY_CANDIDATES.find((entry) => existsSync(entry)) ?? @@ -269,11 +276,13 @@ export class Hono extends HonoBase { assignRouterStatement = 'this.router = new TrieRouter()' } + // "auto" removes the APIs only when every route method is strictly + // GET/HEAD/OPTIONS. A route or middleware registered with ALL may read + // the request body (#64), so its presence keeps the APIs. const removeRequestBodyApi = - options.requestBodyApiRemoval !== false && - app.routes.every(({ method }) => - [METHOD_NAME_ALL, 'GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) - ) + options.requestBodyApiRemoval === 'force' || + (options.requestBodyApiRemoval === 'auto' && + app.routes.every(({ method }) => ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()))) const unusedHonoMethods: Record = ( app as Hono & { unusedMethods: Record } ).unusedMethods