-
Notifications
You must be signed in to change notification settings - Fork 9
feat: introduce optimize command
#23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
73a0788
88f8e03
4425e5c
39e8ece
3da1546
f2dc0f0
198ea35
5333d5c
8b7d975
e35d335
16e4e0b
03193f4
529f9d6
2c09f8c
dba02c6
026e426
701aec4
75596ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,4 +50,4 @@ | |
| "vitest": "^3.2.4" | ||
| }, | ||
| "packageManager": "bun@1.2.20" | ||
| } | ||
| } | ||
| 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) | ||
| } | ||
| ) | ||
| }) |
| 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') | ||
yusukebe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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)`)
})
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the comment. |
||
| 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} | ||
| } | ||
| } | ||
| `, | ||
| } | ||
| }) | ||
| }, | ||
| }, | ||
| ], | ||
| }) | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,4 +13,4 @@ | |
| "jsx": "react-jsx", | ||
| "jsxImportSource": "hono/jsx", | ||
| } | ||
| } | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.