From f98990f3716b986917c785cb97b0b88086e01546 Mon Sep 17 00:00:00 2001 From: robinsadeghpour Date: Thu, 22 Jan 2026 23:59:38 +0100 Subject: [PATCH 1/2] feat(request,serve): add --external flag for marking packages as external (#69) --- README.md | 8 ++++ src/commands/request/index.test.ts | 68 ++++++++++++++++++++++++++++++ src/commands/request/index.ts | 17 ++++++-- src/commands/serve/index.ts | 12 +++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 44cb568..65a8f00 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ hono request [file] [options] - `-d, --data ` - Request body data - `-H, --header
` - Custom headers (can be used multiple times) - `-w, --watch` - Watch for changes and resend request +- `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** @@ -178,6 +179,9 @@ hono request -P /api/protected \ -H 'Authorization: Bearer token' \ -H 'User-Agent: MyApp' \ src/your-app.ts + +# Request with external packages (useful for Node.js native modules) +hono request -e pg -e dotenv src/your-app.ts ``` **Response Format:** @@ -212,6 +216,7 @@ hono serve [entry] [options] - `-p, --port ` - Port number (default: 7070) - `--show-routes` - Show registered routes - `--use ` - Use middleware (can be used multiple times) +- `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** @@ -238,6 +243,9 @@ hono serve --use 'cors()' --use 'logger()' src/app.ts hono serve \ --use 'basicAuth({ username: "foo", password: "bar" })' \ --use "serveStatic({ root: './' })" + +# Start server with external packages (useful for Node.js native modules) +hono serve -e pg -e prisma src/app.ts ``` ### `optimize` diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index e4faa94..17f574b 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -338,4 +338,72 @@ describe('requestCommand', () => { ) ) }) + + it('should handle single external option', async () => { + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const expectedPath = 'test-app.js' + setupBasicMocks(expectedPath, mockApp) + + await program.parseAsync(['node', 'test', 'request', '-e', 'pg', 'test-app.js']) + + expect(mockBuildAndImportApp).toHaveBeenCalledWith(expectedPath, { + external: ['@hono/node-server', 'pg'], + watch: false, + sourcemap: true, + }) + }) + + it('should handle multiple external options', async () => { + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const expectedPath = 'test-app.js' + setupBasicMocks(expectedPath, mockApp) + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-e', + 'pg', + '-e', + 'dotenv', + '-e', + 'prisma', + 'test-app.js', + ]) + + expect(mockBuildAndImportApp).toHaveBeenCalledWith(expectedPath, { + external: ['@hono/node-server', 'pg', 'dotenv', 'prisma'], + watch: false, + sourcemap: true, + }) + }) + + it('should handle long flag name --external', async () => { + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const expectedPath = 'test-app.js' + setupBasicMocks(expectedPath, mockApp) + + await program.parseAsync([ + 'node', + 'test', + 'request', + '--external', + 'pg', + '--external', + 'dotenv', + 'test-app.js', + ]) + + expect(mockBuildAndImportApp).toHaveBeenCalledWith(expectedPath, { + external: ['@hono/node-server', 'pg', 'dotenv'], + watch: false, + sourcemap: true, + }) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index a484333..9f28769 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -12,6 +12,7 @@ interface RequestOptions { header?: string[] path?: string watch: boolean + external?: string[] } export function requestCommand(program: Command) { @@ -31,10 +32,19 @@ export function requestCommand(program: Command) { }, [] as string[] ) + .option( + '-e, --external ', + 'Mark package as external (can be used multiple times)', + (value: string, previous: string[]) => { + return previous ? [...previous, value] : [value] + }, + [] as string[] + ) .action(async (file: string | undefined, options: RequestOptions) => { const path = options.path || '/' const watch = options.watch - const buildIterator = getBuildIterator(file, watch) + const external = options.external || [] + const buildIterator = getBuildIterator(file, watch, external) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) console.log(JSON.stringify(result, null, 2)) @@ -44,7 +54,8 @@ export function requestCommand(program: Command) { export function getBuildIterator( appPath: string | undefined, - watch: boolean + watch: boolean, + external: string[] = [] ): AsyncGenerator { // Determine entry file path let entry: string @@ -68,7 +79,7 @@ export function getBuildIterator( const appFilePath = realpathSync(resolvedAppPath) return buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], + external: ['@hono/node-server', ...external], watch, sourcemap: true, }) diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index 9bd6766..d2e4d5e 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -36,10 +36,18 @@ export function serveCommand(program: Command) { }, [] ) + .option( + '-e, --external ', + 'Mark package as external (can be used multiple times)', + (value: string, previous: string[]) => { + return previous ? [...previous, value] : [value] + }, + [] as string[] + ) .action( async ( entry: string | undefined, - options: { port?: number; showRoutes?: boolean; use?: string[] } + options: { port?: number; showRoutes?: boolean; use?: string[]; external?: string[] } ) => { let app: Hono @@ -55,7 +63,7 @@ export function serveCommand(program: Command) { } else { const appFilePath = realpathSync(appPath) const buildIterator = buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], + external: ['@hono/node-server', ...(options.external || [])], sourcemap: true, }) app = (await buildIterator.next()).value From c1d2530a28f4695fba35526b073b2415afaaca74 Mon Sep 17 00:00:00 2001 From: robinsadeghpour Date: Fri, 23 Jan 2026 19:15:46 +0100 Subject: [PATCH 2/2] refactor(serve): extract options type and add external flag tests --- src/commands/serve/index.test.ts | 142 +++++++++++++++++++++++++++++++ src/commands/serve/index.ts | 124 ++++++++++++++------------- 2 files changed, 205 insertions(+), 61 deletions(-) diff --git a/src/commands/serve/index.test.ts b/src/commands/serve/index.test.ts index d1a21fa..e0d289e 100644 --- a/src/commands/serve/index.test.ts +++ b/src/commands/serve/index.test.ts @@ -266,3 +266,145 @@ export default app ) }) }) + +describe('serveCommand external option', () => { + it('should pass external packages to buildAndImportApp', async () => { + const { Hono } = await import('hono') + const buildModule = await import('../../utils/build.js') + + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ value: mockApp, done: false }) + .mockResolvedValueOnce({ value: undefined, done: true }), + return: vi.fn().mockResolvedValue({ value: undefined, done: true }), + [Symbol.asyncIterator]() { + return this + }, + } + + const buildSpy = vi.spyOn(buildModule, 'buildAndImportApp').mockReturnValue(mockIterator) + + const appDir = mkdtempSync(join(tmpdir(), 'hono-cli-external-test')) + const appFile = join(appDir, 'app.ts') + writeFileSync(appFile, 'export default {}') + + const program = new Command() + const { serveCommand } = await import('./index.js') + serveCommand(program) + + await program.parseAsync(['node', 'test', 'serve', '-e', 'pg', appFile]) + + expect(buildSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + external: ['@hono/node-server', 'pg'], + }) + ) + + buildSpy.mockRestore() + }) + + it('should pass multiple external packages to buildAndImportApp', async () => { + const { Hono } = await import('hono') + const buildModule = await import('../../utils/build.js') + + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ value: mockApp, done: false }) + .mockResolvedValueOnce({ value: undefined, done: true }), + return: vi.fn().mockResolvedValue({ value: undefined, done: true }), + [Symbol.asyncIterator]() { + return this + }, + } + + const buildSpy = vi.spyOn(buildModule, 'buildAndImportApp').mockReturnValue(mockIterator) + + const appDir = mkdtempSync(join(tmpdir(), 'hono-cli-external-test')) + const appFile = join(appDir, 'app.ts') + writeFileSync(appFile, 'export default {}') + + const program = new Command() + const { serveCommand } = await import('./index.js') + serveCommand(program) + + await program.parseAsync([ + 'node', + 'test', + 'serve', + '-e', + 'pg', + '-e', + 'dotenv', + '-e', + 'prisma', + appFile, + ]) + + expect(buildSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + external: ['@hono/node-server', 'pg', 'dotenv', 'prisma'], + }) + ) + + buildSpy.mockRestore() + }) + + it('should handle --external long flag name', async () => { + const { Hono } = await import('hono') + const buildModule = await import('../../utils/build.js') + + const mockApp = new Hono() + mockApp.get('/', (c) => c.json({ message: 'Hello' })) + + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ value: mockApp, done: false }) + .mockResolvedValueOnce({ value: undefined, done: true }), + return: vi.fn().mockResolvedValue({ value: undefined, done: true }), + [Symbol.asyncIterator]() { + return this + }, + } + + const buildSpy = vi.spyOn(buildModule, 'buildAndImportApp').mockReturnValue(mockIterator) + + const appDir = mkdtempSync(join(tmpdir(), 'hono-cli-external-test')) + const appFile = join(appDir, 'app.ts') + writeFileSync(appFile, 'export default {}') + + const program = new Command() + const { serveCommand } = await import('./index.js') + serveCommand(program) + + await program.parseAsync([ + 'node', + 'test', + 'serve', + '--external', + 'pg', + '--external', + 'dotenv', + appFile, + ]) + + expect(buildSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + external: ['@hono/node-server', 'pg', 'dotenv'], + }) + ) + + buildSpy.mockRestore() + }) +}) diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index d2e4d5e..fa764cd 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -8,6 +8,13 @@ import { resolve } from 'node:path' import { buildAndImportApp } from '../../utils/build.js' import { builtinMap } from './builtin-map.js' +interface ServeOptions { + port?: number + showRoutes?: boolean + use?: string[] + external?: string[] +} + // Keep serveStatic to prevent bundler removal ;[serveStatic].forEach((f) => { if (typeof f === 'function') { @@ -44,78 +51,73 @@ export function serveCommand(program: Command) { }, [] as string[] ) - .action( - async ( - entry: string | undefined, - options: { port?: number; showRoutes?: boolean; use?: string[]; external?: string[] } - ) => { - let app: Hono + .action(async (entry: string | undefined, options: ServeOptions) => { + let app: Hono + + if (!entry) { + // Create a default Hono app if no entry is provided + app = new Hono() + } else { + const appPath = resolve(process.cwd(), entry) - if (!entry) { - // Create a default Hono app if no entry is provided + if (!existsSync(appPath)) { + // Create a default Hono app if entry file doesn't exist app = new Hono() } else { - const appPath = resolve(process.cwd(), entry) - - if (!existsSync(appPath)) { - // Create a default Hono app if entry file doesn't exist - app = new Hono() - } else { - const appFilePath = realpathSync(appPath) - const buildIterator = buildAndImportApp(appFilePath, { - external: ['@hono/node-server', ...(options.external || [])], - sourcemap: true, - }) - app = (await buildIterator.next()).value - } + const appFilePath = realpathSync(appPath) + const buildIterator = buildAndImportApp(appFilePath, { + external: ['@hono/node-server', ...(options.external || [])], + sourcemap: true, + }) + app = (await buildIterator.next()).value } + } - // Import all builtin functions from the builtin map - const allFunctions: Record = {} - const uniqueModules = [...new Set(Object.values(builtinMap))] + // Import all builtin functions from the builtin map + const allFunctions: Record = {} + const uniqueModules = [...new Set(Object.values(builtinMap))] - for (const modulePath of uniqueModules) { - try { - const module = await import(modulePath) - // Add all exported functions from this module - for (const [funcName, modulePathInMap] of Object.entries(builtinMap)) { - if (modulePathInMap === modulePath && module[funcName]) { - allFunctions[funcName] = module[funcName] - } + for (const modulePath of uniqueModules) { + try { + const module = await import(modulePath) + // Add all exported functions from this module + for (const [funcName, modulePathInMap] of Object.entries(builtinMap)) { + if (modulePathInMap === modulePath && module[funcName]) { + allFunctions[funcName] = module[funcName] } - } catch (error) { - // Skip modules that can't be imported (optional dependencies) } + } catch (error) { + // Skip modules that can't be imported (optional dependencies) } + } - const baseApp = new Hono() - // Apply middleware from --use options - for (const use of options.use || []) { - // Create function with all available functions in scope - const functionNames = Object.keys(allFunctions) - const functionValues = Object.values(allFunctions) - const func = new Function('c', 'next', ...functionNames, `return (${use})`) - baseApp.use(async (c, next) => { - const middleware = func(c, next, ...functionValues) - return typeof middleware === 'function' ? middleware(c, next) : middleware - }) - } - - baseApp.route('/', app) + const baseApp = new Hono() + // Apply middleware from --use options + for (const use of options.use || []) { + // Create function with all available functions in scope + const functionNames = Object.keys(allFunctions) + const functionValues = Object.values(allFunctions) + const func = new Function('c', 'next', ...functionNames, `return (${use})`) + baseApp.use(async (c, next) => { + const middleware = func(c, next, ...functionValues) + return typeof middleware === 'function' ? middleware(c, next) : middleware + }) + } - if (options.showRoutes) { - showRoutes(baseApp) - } + baseApp.route('/', app) - serve( - { - fetch: baseApp.fetch, - port: options.port ?? 7070, - }, - (info) => { - console.log(`Listening on http://localhost:${info.port}`) - } - ) + if (options.showRoutes) { + showRoutes(baseApp) } - ) + + serve( + { + fetch: baseApp.fetch, + port: options.port ?? 7070, + }, + (info) => { + console.log(`Listening on http://localhost:${info.port}`) + } + ) + }) }