diff --git a/bun.lock b/bun.lock index a77f6fd..8044af3 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@hono/cli", "dependencies": { "@hono/node-server": "^1.19.5", - "commander": "^14.0.1", + "@takojs/tako": "^1.4.0", "esbuild": "^0.25.10", "hono": "^4.9.12", }, @@ -227,6 +228,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@takojs/tako": ["@takojs/tako@1.4.0", "", {}, "sha512-nV5Y2dSFgTA/KAvCdsXYWGKtI6yox590eBeNomyJYoajkUFYpKJqElsG6VHNxTlBPVrZr9RP93+MBgCjcXTAKg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -381,7 +384,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="], @@ -1111,8 +1114,6 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/package.json b/package.json index cd9cd0e..a45185a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@hono/cli", "version": "0.1.1", + "description": "CLI for Hono", "type": "module", "bin": { "hono": "dist/cli.js" @@ -34,7 +35,7 @@ "homepage": "https://hono.dev", "dependencies": { "@hono/node-server": "^1.19.5", - "commander": "^14.0.1", + "@takojs/tako": "^1.4.0", "esbuild": "^0.25.10", "hono": "^4.9.12" }, diff --git a/src/cli.ts b/src/cli.ts index 800ba72..cf4de38 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,31 +1,26 @@ -import { Command } from 'commander' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { docsCommand } from './commands/docs/index.js' -import { optimizeCommand } from './commands/optimize/index.js' -import { requestCommand } from './commands/request/index.js' -import { searchCommand } from './commands/search/index.js' -import { serveCommand } from './commands/serve/index.js' +import { Tako } from '@takojs/tako' +import pkg from '../package.json' with { type: 'json' } +import { docsArgs, docsCommand, docsValidation } from './commands/docs/index.js' +import { optimizeArgs, optimizeCommand, optimizeValidation } from './commands/optimize/index.js' +import { requestArgs, requestCommand, requestValidation } from './commands/request/index.js' +import { searchArgs, searchCommand, searchValidation } from './commands/search/index.js' +import { serveArgs, serveCommand, serveValidation } from './commands/serve/index.js' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const rootArgs = { + metadata: { + cliName: 'hono', + version: pkg.version, + help: pkg.description, + }, +} -// Read version from package.json -const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) - -const program = new Command() - -program - .name('hono') - .description('CLI for Hono') - .version(packageJson.version, '-v, --version', 'display version number') +const tako = new Tako() // Register commands -docsCommand(program) -optimizeCommand(program) -searchCommand(program) -requestCommand(program) -serveCommand(program) +tako.command('docs', docsArgs, docsValidation, docsCommand) +tako.command('optimize', optimizeArgs, optimizeValidation, optimizeCommand) +tako.command('request', requestArgs, requestValidation, requestCommand) +tako.command('search', searchArgs, searchValidation, searchCommand) +tako.command('serve', serveArgs, serveValidation, serveCommand) -program.parse() +await tako.cli(rootArgs) diff --git a/src/commands/docs/index.test.ts b/src/commands/docs/index.test.ts index 6b7c99c..91f27c2 100644 --- a/src/commands/docs/index.test.ts +++ b/src/commands/docs/index.test.ts @@ -1,20 +1,21 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Buffer } from 'node:buffer' +import * as process from 'node:process' +import { docsArgs, docsCommand, docsValidation } from './index.js' // Mock fetch -global.fetch = vi.fn() - -import { docsCommand } from './index.js' +globalThis.fetch = vi.fn() describe('docsCommand', () => { - let program: Command + let tako: Tako let consoleLogSpy: ReturnType let consoleErrorSpy: ReturnType let originalIsTTY: boolean | undefined beforeEach(() => { - program = new Command() - docsCommand(program) + tako = new Tako() + tako.command('docs', docsArgs, docsValidation, docsCommand) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -46,7 +47,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockContent), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith('https://hono.dev/llms.txt') expect(consoleLogSpy).toHaveBeenCalledWith('Fetching Hono documentation...') @@ -61,7 +62,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', '/docs/concepts/stacks']) + await tako.cli({ config: { args: ['docs', '/docs/concepts/stacks'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/concepts/stacks.md' @@ -80,7 +81,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', '/examples/stytch-auth']) + await tako.cli({ config: { args: ['docs', '/examples/stytch-auth'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/examples/stytch-auth.md' @@ -99,7 +100,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', 'examples/basic']) + await tako.cli({ config: { args: ['docs', 'examples/basic'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/examples/basic.md' @@ -115,7 +116,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -131,7 +132,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs', '/docs/concepts/motivation']) + await tako.cli({ config: { args: ['docs', '/docs/concepts/motivation'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -149,7 +150,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs', '/examples/stytch-auth']) + await tako.cli({ config: { args: ['docs', '/examples/stytch-auth'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -164,7 +165,7 @@ describe('docsCommand', () => { const networkError = new Error('Network error') vi.mocked(fetch).mockRejectedValue(networkError) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching documentation:', 'Network error') expect(consoleLogSpy).toHaveBeenCalledWith('\nPlease visit: https://hono.dev/docs') @@ -189,7 +190,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/concepts/middleware.md' @@ -222,7 +223,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/api/context.md' @@ -246,7 +247,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockContent), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith('https://hono.dev/llms.txt') expect(consoleLogSpy).toHaveBeenCalledWith('Fetching Hono documentation...') diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts index a7cfaae..8d25cd9 100644 --- a/src/commands/docs/index.ts +++ b/src/commands/docs/index.ts @@ -1,6 +1,8 @@ -import type { Command } from 'commander' +import type { Tako, TakoArgs, TakoHandler } from '@takojs/tako' +import { Buffer } from 'node:buffer' +import * as process from 'node:process' -async function fetchAndDisplayContent(url: string, fallbackUrl?: string): Promise { +async function fetchAndDisplayContent(c: Tako, url: string, fallbackUrl?: string): Promise { try { const response = await fetch(url) @@ -9,64 +11,85 @@ async function fetchAndDisplayContent(url: string, fallbackUrl?: string): Promis } const content = await response.text() - console.log('\n' + content) + c.print({ message: '\n' + content }) } catch (error) { - console.error( - 'Error fetching documentation:', - error instanceof Error ? error.message : String(error) - ) - console.log(`\nPlease visit: ${fallbackUrl || 'https://hono.dev/docs'}`) + c.print({ + message: [ + 'Error fetching documentation:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + c.print({ message: `\nPlease visit: ${fallbackUrl || 'https://hono.dev/docs'}` }) + } +} + +async function getPath(c: Tako): Promise { + const pathFromArgs = c.scriptArgs.positionals[0] + if (pathFromArgs) { + return pathFromArgs + } + + // If no path provided, check for stdin input + // Check if stdin is piped (not a TTY) + if (process.stdin.isTTY) { + return + } + + try { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + const stdinInput = Buffer.concat(chunks).toString().trim() + if (!stdinInput) { + return + } + // Remove quotes if present (handles jq output without -r flag) + return stdinInput.replace(/^["'](.*)["']$/, '$1') + } catch (error) { + c.print({ + message: [ + 'Error reading from stdin:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + return } } -export function docsCommand(program: Command) { - program - .command('docs') - .argument( - '[path]', - 'Documentation path (e.g., /docs/concepts/motivation, /examples/stytch-auth)', - '' - ) - .description('Display Hono documentation') - .action(async (path: string) => { - let finalPath = path +export const docsArgs: TakoArgs = { + metadata: { + help: 'Display Hono documentation', + placeholder: '[path]', + }, +} - // If no path provided, check for stdin input - if (!path) { - // Check if stdin is piped (not a TTY) - if (!process.stdin.isTTY) { - try { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk) - } - const stdinInput = Buffer.concat(chunks).toString().trim() - if (stdinInput) { - // Remove quotes if present (handles jq output without -r flag) - finalPath = stdinInput.replace(/^["'](.*)["']$/, '$1') - } - } catch (error) { - console.error('Error reading from stdin:', error) - } - } +export const docsValidation: TakoHandler = async (_c, next) => { + await next() +} + +export const docsCommand: TakoHandler = async (c) => { + const finalPath = await getPath(c) - // If still no path, fetch llms.txt - if (!finalPath) { - console.log('Fetching Hono documentation...') - await fetchAndDisplayContent('https://hono.dev/llms.txt') - return - } - } + if (!finalPath) { + // If still no path, fetch llms.txt + c.print({ message: 'Fetching Hono documentation...' }) + await fetchAndDisplayContent(c, 'https://hono.dev/llms.txt') + return + } - // Ensure path starts with / - const normalizedPath = finalPath.startsWith('/') ? finalPath : `/${finalPath}` + // Ensure path starts with / + const normalizedPath = finalPath.startsWith('/') ? finalPath : `/${finalPath}` - // Remove leading slash to get the GitHub path - const basePath = normalizedPath.slice(1) // Remove leading slash - const markdownUrl = `https://raw.githubusercontent.com/honojs/website/refs/heads/main/${basePath}.md` - const webUrl = `https://hono.dev${normalizedPath}` + // Remove leading slash to get the GitHub path + const basePath = normalizedPath.slice(1) // Remove leading slash + const markdownUrl = `https://raw.githubusercontent.com/honojs/website/refs/heads/main/${basePath}.md` + const webUrl = `https://hono.dev${normalizedPath}` - console.log(`Fetching Hono documentation for ${finalPath}...`) - await fetchAndDisplayContent(markdownUrl, webUrl) - }) + c.print({ message: `Fetching Hono documentation for ${finalPath}...` }) + await fetchAndDisplayContent(c, markdownUrl, webUrl) } diff --git a/src/commands/optimize/index.test.ts b/src/commands/optimize/index.test.ts index b0941b0..51c8646 100644 --- a/src/commands/optimize/index.test.ts +++ b/src/commands/optimize/index.test.ts @@ -1,13 +1,14 @@ -import { Command } from 'commander' -import { describe, it, expect, beforeEach } from 'vitest' +import { Tako } from '@takojs/tako' +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 * as process from 'node:process' +import { optimizeArgs, optimizeCommand, optimizeValidation } from './index' -const program = new Command() -optimizeCommand(program) +const tako = new Tako() +tako.command('optimize', optimizeArgs, optimizeValidation, optimizeCommand) const npmInstall = async () => new Promise((resolve) => { @@ -27,7 +28,7 @@ describe('optimizeCommand', () => { it('should throws an error if entry file not found', async () => { await expect( - program.parseAsync(['node', 'hono', 'optimize', './non-existent-file.ts']) + tako.cli({ config: { args: ['optimize', './non-existent-file.ts'] } }) ).rejects.toThrowError() }) @@ -216,7 +217,7 @@ describe('optimizeCommand', () => { for (const file of files) { writeFileSync(join(dir, file.path), file.content) } - await program.parseAsync(['node', 'hono', 'optimize', ...(args ?? [])]) + await tako.cli({ config: { args: ['optimize', ...(args ?? [])] } }) const content = readFileSync(join(dir, result.path), 'utf-8') if (result.lineCount) { diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts index 415d74e..ce2bec2 100644 --- a/src/commands/optimize/index.ts +++ b/src/commands/optimize/index.ts @@ -1,117 +1,147 @@ -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' 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, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import * as process from 'node:process' 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') - .option('-m, --minify', 'minify output file') - .action(async (entry: string, options: { outfile: string; minify?: 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 appFilePath = realpathSync(appPath) - const buildIterator = buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], +export const optimizeArgs: TakoArgs = { + config: { + options: { + outfile: { + type: 'string', + short: 'o', + default: 'dist/index.js', + }, + minify: { + type: 'boolean', + short: 'm', + }, + }, + }, + metadata: { + help: 'Build optimized Hono class', + placeholder: '[entry]', + options: { + outfile: { + help: 'output file', + placeholder: '', + }, + minify: { + help: 'minify output file', + }, + }, + }, +} + +export const optimizeValidation: TakoHandler = async (_c, next) => { + await next() +} + +export const optimizeCommand: TakoHandler = async (c) => { + let entry = c.scriptArgs.positionals[0] + const { outfile, minify } = c.scriptArgs.values as { outfile?: string; minify?: 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 appFilePath = realpathSync(appPath) + const buildIterator = buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + }) + 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 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} + 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()' - } - - console.log('[Optimized]') - console.log(` Router: ${routerName}`) - - const outfile = resolve(process.cwd(), options.outfile) - await esbuild.build({ - entryPoints: [appFilePath], - outfile, - bundle: true, - minify: options.minify, - 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: ` + } 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()' + } + + console.log('[Optimized]') + console.log(` Router: ${routerName}`) + + const outputFilename = outfile || 'dist/index.js' + const absoluteOutfile = resolve(process.cwd(), outputFilename) + await esbuild.build({ + entryPoints: [appFilePath], + outfile: absoluteOutfile, + bundle: true, + minify: minify, + 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 { @@ -121,14 +151,13 @@ export class Hono extends HonoBase { } } `, - } - }) - }, - }, - ], - }) + } + }) + }, + }, + ], + }) - const outfileStat = statSync(outfile) - console.log(` Output: ${options.outfile} (${(outfileStat.size / 1024).toFixed(2)} KB)`) - }) + const outfileStat = statSync(absoluteOutfile) + console.log(` Output: ${outputFilename} (${(outfileStat.size / 1024).toFixed(2)} KB)`) } diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 3790cfd..98a2816 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -1,6 +1,8 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import * as process from 'node:process' +import { requestArgs, requestCommand, requestValidation } from './index.js' // Mock dependencies vi.mock('node:fs', () => ({ @@ -16,10 +18,8 @@ vi.mock('../../utils/build.js', () => ({ buildAndImportApp: vi.fn(), })) -import { requestCommand } from './index.js' - describe('requestCommand', () => { - let program: Command + let tako: Tako let consoleLogSpy: ReturnType let mockModules: any let mockBuildAndImportApp: any @@ -48,8 +48,8 @@ describe('requestCommand', () => { } beforeEach(async () => { - program = new Command() - requestCommand(program) + tako = new Tako() + tako.command('request', requestArgs, requestValidation, requestCommand) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // Get mocked modules @@ -76,7 +76,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-P', '/', 'test-app.js'] } }) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -106,7 +106,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-w', '-P', '/', 'test-app.js'] } }) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -139,18 +139,11 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/data', - '-X', - 'POST', - '-d', - 'test data', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: ['request', '-P', '/data', '-X', 'POST', '-d', 'test data', 'test-app.js'], + }, + }) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -185,7 +178,7 @@ describe('requestCommand', () => { }) mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) - await program.parseAsync(['node', 'test', 'request']) + await tako.cli({ config: { args: ['request'] } }) // Verify resolve was called with correct arguments for default candidates expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.ts') @@ -218,16 +211,11 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/test', - '-H', - 'Authorization: Bearer token123', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: ['request', '-P', '/api/test', '-H', 'Authorization: Bearer token123', 'test-app.js'], + }, + }) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -254,20 +242,22 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/multi', - '-H', - 'Authorization: Bearer token456', - '-H', - 'User-Agent: TestClient/1.0', - '-H', - 'X-Custom-Header: custom-value', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/api/multi', + '-H', + 'Authorization: Bearer token456', + '-H', + 'User-Agent: TestClient/1.0', + '-H', + 'X-Custom-Header: custom-value', + 'test-app.js', + ], + }, + }) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -292,7 +282,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/api/noheader', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-P', '/api/noheader', 'test-app.js'] } }) // Should not include any custom headers, only default ones const output = consoleLogSpy.mock.calls[0][0] as string @@ -310,18 +300,20 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/malformed', - '-H', - 'MalformedHeader', // Missing colon - '-H', - 'ValidHeader: value', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/api/malformed', + '-H', + 'MalformedHeader', // Missing colon + '-H', + 'ValidHeader: value', + 'test-app.js', + ], + }, + }) // Should still work, malformed header is ignored expect(consoleLogSpy).toHaveBeenCalledWith( diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index cc84a54..2522498 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -1,7 +1,8 @@ -import type { Command } from 'commander' +import type { Tako, TakoArgs, TakoHandler } from '@takojs/tako' import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' +import * as process from 'node:process' import { buildAndImportApp } from '../../utils/build.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -11,35 +12,6 @@ interface RequestOptions { data?: string header?: string[] path?: string - watch: boolean -} - -export function requestCommand(program: Command) { - program - .command('request') - .description('Send request to Hono app using app.request()') - .argument('[file]', 'Path to the Hono app file') - .option('-P, --path ', 'Request path', '/') - .option('-X, --method ', 'HTTP method', 'GET') - .option('-d, --data ', 'Request body data') - .option('-w, --watch', 'Watch for changes and resend request', false) - .option( - '-H, --header
', - 'Custom headers', - (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) - for await (const app of buildIterator) { - const result = await executeRequest(app, path, options) - console.log(JSON.stringify(result, null, 2)) - } - }) } export function getBuildIterator( @@ -73,27 +45,32 @@ export function getBuildIterator( }) } -export async function executeRequest( - app: Hono, - requestPath: string, - options: RequestOptions +async function executeRequest( + c: Tako, + app: Hono ): Promise<{ status: number; body: string; headers: Record }> { + if (!app || typeof app.request !== 'function') { + throw new Error('No valid Hono app exported from the file') + } + + const { path: requestPath, method, data, header } = c.scriptArgs.values as RequestOptions + // Build request - const url = new URL(requestPath, 'http://localhost') + const url = new URL(requestPath || '/', 'http://localhost') const requestInit: RequestInit = { - method: options.method || 'GET', + method: method || 'GET', } // Add request body if provided - if (options.data) { - requestInit.body = options.data + if (data) { + requestInit.body = data } // Add headers if provided - if (options.header && options.header.length > 0) { + if (header && header.length > 0) { const headers = new Headers() - for (const header of options.header) { - const [key, value] = header.split(':', 2) + for (const h of header) { + const [key, value] = h.split(':', 2) if (key && value) { headers.set(key.trim(), value.trim()) } @@ -119,3 +96,85 @@ export async function executeRequest( headers: responseHeaders, } } + +export const requestArgs: TakoArgs = { + config: { + options: { + path: { + type: 'string', + short: 'P', + default: '/', + }, + method: { + type: 'string', + short: 'X', + default: 'GET', + }, + data: { + type: 'string', + short: 'd', + }, + header: { + type: 'string', + short: 'H', + multiple: true, + }, + watch: { + type: 'boolean', + short: 'w', + default: false, + }, + }, + }, + metadata: { + help: 'Send request to Hono app using app.request()', + placeholder: '[file]', + options: { + path: { + help: 'Request path', + placeholder: '', + }, + method: { + help: 'HTTP method', + placeholder: '', + }, + data: { + help: 'Request body data', + placeholder: '', + }, + header: { + help: 'Custom headers', + placeholder: '
', + }, + watch: { + help: 'Watch for changes and resend request', + placeholder: '
', + }, + }, + }, +} + +export const requestValidation: TakoHandler = async (_c, next) => { + await next() +} + +export const requestCommand: TakoHandler = async (c) => { + try { + const file = c.scriptArgs.positionals[0] + const { watch } = c.scriptArgs.values as { watch: boolean } + + const buildIterator = getBuildIterator(file, watch) + for await (const app of buildIterator) { + const result = await executeRequest(c, app) + if (result) { + c.print({ message: JSON.stringify(result, null, 2) }) + } + } + } catch (error) { + c.print({ + message: ['Error:', error instanceof Error ? error.message : String(error)], + style: 'red', + level: 'error', + }) + } +} diff --git a/src/commands/search/index.test.ts b/src/commands/search/index.test.ts index d4f25e0..ec13235 100644 --- a/src/commands/search/index.test.ts +++ b/src/commands/search/index.test.ts @@ -1,16 +1,17 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { searchCommand } from './index.js' +import { searchArgs, searchCommand, searchValidation } from './index.js' // Mock fetch globally const mockFetch = vi.fn() -global.fetch = mockFetch +globalThis.fetch = mockFetch describe('Search Command', () => { - let program: Command + let tako: Tako beforeEach(() => { - program = new Command() + tako = new Tako() + tako.command('search', searchArgs, searchValidation, searchCommand) vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -44,9 +45,8 @@ describe('Search Command', () => { }) const logSpy = vi.spyOn(console, 'log') - searchCommand(program) - await program.parseAsync(['node', 'test', 'search', 'middleware']) + await tako.cli({ config: { args: ['search', 'middleware'] } }) // Get the JSON output const jsonOutput = logSpy.mock.calls[0][0] @@ -88,9 +88,8 @@ describe('Search Command', () => { }) const logSpy = vi.spyOn(console, 'log') - searchCommand(program) - await program.parseAsync(['node', 'test', 'search', 'nonexistent']) + await tako.cli({ config: { args: ['search', 'nonexistent'] } }) const jsonOutput = logSpy.mock.calls[0][0] @@ -114,9 +113,7 @@ describe('Search Command', () => { statusText: 'Not Found', }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test']) + await tako.cli({ config: { args: ['search', 'test'] } }) expect(errorSpy).toHaveBeenCalled() }) @@ -129,9 +126,7 @@ describe('Search Command', () => { json: async () => mockResponse, }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test', '--limit', '3']) + await tako.cli({ config: { args: ['search', 'test', '--limit', '3'] } }) expect(mockFetch).toHaveBeenCalledWith( 'https://1GIFSU1REV-dsn.algolia.net/1/indexes/hono/query', @@ -160,9 +155,7 @@ describe('Search Command', () => { json: async () => mockResponse, }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test', '--limit', String(limit)]) + await tako.cli({ config: { args: ['search', 'test', '--limit', String(limit)] } }) expect(warnSpy).toHaveBeenCalledWith('Limit must be a number between 1 and 20\n') diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts index 1266749..7762880 100644 --- a/src/commands/search/index.ts +++ b/src/commands/search/index.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' interface AlgoliaHit { title?: string @@ -29,142 +29,184 @@ interface AlgoliaResponse { hits: AlgoliaHit[] } -export function searchCommand(program: Command) { - program - .command('search') - .argument('', 'Search query for Hono documentation') - .option('-l, --limit ', 'Number of results to show (default: 5)', (value) => { - const parsed = parseInt(value, 10) - if (isNaN(parsed) || parsed < 1 || parsed > 20) { - console.warn('Limit must be a number between 1 and 20\n') - return 5 - } - return parsed +export const searchArgs: TakoArgs = { + config: { + options: { + limit: { + type: 'string', + short: 'l', + }, + pretty: { + type: 'boolean', + short: 'p', + }, + }, + }, + metadata: { + help: 'Search Hono documentation', + required: true, + placeholder: '', + options: { + limit: { + help: 'Number of results to show (default: 5)', + placeholder: '', + }, + pretty: { + help: 'Display results in human-readable format', + }, + }, + }, +} + +export const searchValidation: TakoHandler = async (c, next) => { + const { limit } = c.scriptArgs.values as { limit?: string } + if (limit) { + const parsed = parseInt(limit, 10) + if (isNaN(parsed) || parsed < 1 || parsed > 20) { + c.print({ + message: 'Limit must be a number between 1 and 20\n', + style: 'yellow', + level: 'warn', + }) + c.args.values.limit = 5 + } else { + c.args.values.limit = parsed + } + } + await next() +} + +export const searchCommand: TakoHandler = async (c) => { + const query = c.scriptArgs.positionals[0] + const { pretty } = c.scriptArgs.values + const { limit } = c.args.values as { limit?: number } + + // Search-only API key - safe to embed in public code + const ALGOLIA_APP_ID = '1GIFSU1REV' + const ALGOLIA_API_KEY = 'c6a0f86b9a9f8551654600f28317a9e9' + const ALGOLIA_INDEX = 'hono' + + const searchUrl = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${ALGOLIA_INDEX}/query` + + try { + if (pretty) { + c.print({ message: `Searching for "${query}"...` }) + } + + const response = await fetch(searchUrl, { + method: 'POST', + headers: { + 'X-Algolia-API-Key': ALGOLIA_API_KEY, + 'X-Algolia-Application-Id': ALGOLIA_APP_ID, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + hitsPerPage: limit || 5, + }), }) - .option('-p, --pretty', 'Display results in human-readable format') - .description('Search Hono documentation') - .action(async (query: string, options: { limit?: number; pretty?: boolean }) => { - // Search-only API key - safe to embed in public code - const ALGOLIA_APP_ID = '1GIFSU1REV' - const ALGOLIA_API_KEY = 'c6a0f86b9a9f8551654600f28317a9e9' - const ALGOLIA_INDEX = 'hono' - - const searchUrl = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${ALGOLIA_INDEX}/query` - - try { - if (options.pretty) { - console.log(`Searching for "${query}"...`) - } - const response = await fetch(searchUrl, { - method: 'POST', - headers: { - 'X-Algolia-API-Key': ALGOLIA_API_KEY, - 'X-Algolia-Application-Id': ALGOLIA_APP_ID, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - hitsPerPage: options.limit || 5, - }), - }) + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`) + } - if (!response.ok) { - throw new Error(`Search failed: ${response.status} ${response.statusText}`) - } + const data: AlgoliaResponse = await response.json() - const data: AlgoliaResponse = await response.json() + if (data.hits.length === 0) { + if (pretty) { + c.print({ message: '\nNo results found.' }) + } else { + c.print({ message: JSON.stringify({ query, total: 0, results: [] }, null, 2) }) + } + return + } - if (data.hits.length === 0) { - if (options.pretty) { - console.log('\nNo results found.') - } else { - console.log(JSON.stringify({ query, total: 0, results: [] }, null, 2)) - } - return - } + // Helper function to clean HTML tags completely + const cleanHighlight = (text: string) => text.replace(/<[^>]*>/g, '') - // Helper function to clean HTML tags completely - const cleanHighlight = (text: string) => text.replace(/<[^>]*>/g, '') - - const results = data.hits.map((hit) => { - // Get title from various sources - let title = hit.title - let highlightedTitle = title - if (!title && hit._highlightResult?.hierarchy?.lvl1) { - title = cleanHighlight(hit._highlightResult.hierarchy.lvl1.value) - highlightedTitle = hit._highlightResult.hierarchy.lvl1.value - } - if (!title) { - title = hit.hierarchy?.lvl1 || hit.hierarchy?.lvl0 || 'Untitled' - highlightedTitle = title - } - - // Build hierarchy path - const hierarchyParts: string[] = [] - if (hit.hierarchy?.lvl0 && hit.hierarchy.lvl0 !== 'Documentation') { - hierarchyParts.push(hit.hierarchy.lvl0) - } - if (hit.hierarchy?.lvl1 && hit.hierarchy.lvl1 !== title) { - hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl1)) - } - if (hit.hierarchy?.lvl2) { - hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl2)) - } - - const category = hierarchyParts.length > 0 ? hierarchyParts.join(' > ') : '' - const url = hit.url - const urlPath = new URL(url).pathname - - return { - title, - highlightedTitle, - category, - url, - path: urlPath, - } - }) + const results = data.hits.map((hit) => { + // Get title from various sources + let title = hit.title + let highlightedTitle = title + if (!title && hit._highlightResult?.hierarchy?.lvl1) { + title = cleanHighlight(hit._highlightResult.hierarchy.lvl1.value) + highlightedTitle = hit._highlightResult.hierarchy.lvl1.value + } + if (!title) { + title = hit.hierarchy?.lvl1 || hit.hierarchy?.lvl0 || 'Untitled' + highlightedTitle = title + } - if (options.pretty) { - console.log(`\nFound ${data.hits.length} results:\n`) - - // Helper function to convert HTML highlights to terminal formatting - const formatHighlight = (text: string) => { - return text - .replace(//g, '\x1b[33m') // Yellow - .replace(/<\/span>/g, '\x1b[0m') // Reset - } - - results.forEach((result, index) => { - console.log(`${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}`) - if (result.category) { - console.log(` Category: ${result.category}`) - } - console.log(` URL: ${result.url}`) - console.log(` Command: hono docs ${result.path}`) - console.log('') - }) - } else { - // Remove highlighted title from JSON output - const jsonResults = results.map(({ highlightedTitle, ...result }) => result) - console.log( - JSON.stringify( - { - query, - total: data.hits.length, - results: jsonResults, - }, - null, - 2 - ) - ) - } - } catch (error) { - console.error( - 'Error searching documentation:', - error instanceof Error ? error.message : String(error) - ) - console.log('\nPlease visit: https://hono.dev/docs') + // Build hierarchy path + const hierarchyParts: string[] = [] + if (hit.hierarchy?.lvl0 && hit.hierarchy.lvl0 !== 'Documentation') { + hierarchyParts.push(hit.hierarchy.lvl0) + } + if (hit.hierarchy?.lvl1 && hit.hierarchy.lvl1 !== title) { + hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl1)) + } + if (hit.hierarchy?.lvl2) { + hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl2)) + } + + const category = hierarchyParts.length > 0 ? hierarchyParts.join(' > ') : '' + const url = hit.url + const urlPath = new URL(url).pathname + + return { + title, + highlightedTitle, + category, + url, + path: urlPath, + } + }) + + if (pretty) { + c.print({ message: `\nFound ${data.hits.length} results:\n` }) + + // Helper function to convert HTML highlights to terminal formatting + const formatHighlight = (text: string) => { + return text + .replace(//g, '\x1b[33m') // Yellow + .replace(/<\/span>/g, '\x1b[0m') // Reset } + + results.forEach((result, index) => { + c.print({ + message: `${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}`, + }) + if (result.category) { + c.print({ message: ` Category: ${result.category}` }) + } + c.print({ message: ` URL: ${result.url}` }) + c.print({ message: ` Command: hono docs ${result.path}` }) + c.print({ message: '' }) + }) + } else { + // Remove highlighted title from JSON output + const jsonResults = results.map(({ highlightedTitle, ...result }) => result) + c.print({ + message: JSON.stringify( + { + query, + total: data.hits.length, + results: jsonResults, + }, + null, + 2 + ), + }) + } + } catch (error) { + c.print({ + message: [ + 'Error searching documentation:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', }) + c.print({ message: '\nPlease visit: https://hono.dev/docs' }) + } } diff --git a/src/commands/serve/index.test.ts b/src/commands/serve/index.test.ts index addc9f9..3ddcbde 100644 --- a/src/commands/serve/index.test.ts +++ b/src/commands/serve/index.test.ts @@ -1,9 +1,11 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { execFile } from 'node:child_process' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as process from 'node:process' +import { serveArgs, serveCommand, serveValidation } from './index.js' // Mock dependencies vi.mock('@hono/node-server', () => ({ @@ -25,10 +27,8 @@ vi.mock('./builtin-map.js', () => ({ }, })) -import { serveCommand } from './index.js' - describe('serveCommand', () => { - let program: Command + let tako: Tako let mockEsbuild: any let mockModules: any let mockServe: any @@ -36,8 +36,8 @@ describe('serveCommand', () => { let capturedFetchFunction: any beforeEach(async () => { - program = new Command() - serveCommand(program) + tako = new Tako() + tako.command('serve', serveArgs, serveValidation, serveCommand) mockServe = vi.mocked((await import('@hono/node-server')).serve) mockShowRoutes = vi.mocked((await import('hono/dev')).showRoutes) @@ -58,7 +58,7 @@ describe('serveCommand', () => { }) it('should start server with default port', async () => { - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) // Verify serve was called with default port 7070 expect(mockServe).toHaveBeenCalledWith( @@ -71,7 +71,7 @@ describe('serveCommand', () => { }) it('should start server with custom port', async () => { - await program.parseAsync(['node', 'test', 'serve', '-p', '8080']) + await tako.cli({ config: { args: ['serve', '-p', '8080'] } }) // Verify serve was called with custom port expect(mockServe).toHaveBeenCalledWith( @@ -116,7 +116,7 @@ export default app }) }) - await program.parseAsync(['node', 'test', 'serve', appFile]) + await tako.cli({ config: { args: ['serve', appFile] } }) // Test the captured fetch function const rootRequest = new Request('http://localhost:7070/') @@ -130,7 +130,7 @@ export default app }) it('should return 404 for non-existent routes when no app file exists', async () => { - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) // Test 404 behavior with default empty app const request = new Request('http://localhost:7070/non-existent') @@ -139,7 +139,7 @@ export default app }) it('should create default empty app when no entry argument provided', async () => { - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) // Verify serve was called expect(mockServe).toHaveBeenCalledWith( @@ -193,15 +193,17 @@ export default app vi.doMock('hono/basic-auth', () => ({ basicAuth: mockBasicAuth })) vi.doMock('hono/proxy', () => ({ proxy: mockProxy })) - await program.parseAsync([ - 'node', - 'test', - 'serve', - '--use', - 'basicAuth({username: "hono", password: "hono"})', - '--use', - '(c) => proxy(`https://ramen-api.dev${new URL(c.req.url).pathname}`)', - ]) + await tako.cli({ + config: { + args: [ + 'serve', + '--use', + 'basicAuth({username: "hono", password: "hono"})', + '--use', + '(c) => proxy(`https://ramen-api.dev${new URL(c.req.url).pathname}`)', + ], + }, + }) // Test without auth - should get 401 const unauthorizedRequest = new Request('http://localhost:7070/shops') diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index d8c6064..a3e11de 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -1,10 +1,11 @@ import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' import { Hono } from 'hono' import { showRoutes } from 'hono/dev' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' +import * as process from 'node:process' import { buildAndImportApp } from '../../utils/build.js' import { builtinMap } from './builtin-map.js' @@ -15,92 +16,112 @@ import { builtinMap } from './builtin-map.js' } }) -export function serveCommand(program: Command) { - program - .command('serve') - .description('Start server') - .argument('[entry]', 'entry file') - .option('-p, --port ', 'port number') - .option('--show-routes', 'show registered routes') - .option( - '--use ', - 'use middleware', - (value, previous: string[]) => { - return previous ? [...previous, value] : [value] +export const serveArgs: TakoArgs = { + config: { + options: { + port: { + type: 'string', + short: 'p', }, - [] - ) - .action( - async ( - entry: string | undefined, - options: { port?: string; showRoutes?: boolean; use?: string[] } - ) => { - let app: Hono + 'show-routes': { + type: 'boolean', + }, + use: { + type: 'string', + multiple: true, + }, + }, + }, + metadata: { + help: 'Start server', + placeholder: '[entry]', + options: { + port: { + help: 'port number', + placeholder: '', + }, + 'show-routes': { + help: 'show registered routes', + }, + use: { + help: 'use middleware', + placeholder: '', + }, + }, + }, +} - if (!entry) { - // Create a default Hono app if no entry is provided - app = new Hono() - } else { - const appPath = resolve(process.cwd(), entry) +export const serveValidation: TakoHandler = async (_c, next) => { + await next() +} - 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'], - }) - app = (await buildIterator.next()).value - } - } +export const serveCommand: TakoHandler = async (c) => { + const entry = c.scriptArgs.positionals[0] + const { port, 'show-routes': showRoutesOption, use: useOptions } = c.scriptArgs.values + let app: Hono - // Import all builtin functions from the builtin map - const allFunctions: Record = {} - const uniqueModules = [...new Set(Object.values(builtinMap))] + if (!entry) { + // Create a default Hono app if no entry is provided + app = new Hono() + } else { + const appPath = resolve(process.cwd(), entry) - 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) - } - } + 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'], + }) + app = (await buildIterator.next()).value + } + } + + // Import all builtin functions from the builtin map + const allFunctions: Record = {} + const uniqueModules = [...new Set(Object.values(builtinMap))] - 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 - }) + 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 { + // Skip modules that can't be imported (optional dependencies) + } + } - baseApp.route('/', app) + const baseApp = new Hono() + // Apply middleware from --use options + for (const use of (useOptions as string[] | undefined) || []) { + // 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 ? Number.parseInt(options.port) : 7070, - }, - (info) => { - console.log(`Listening on http://localhost:${info.port}`) - } - ) - } - ) + if (showRoutesOption) { + showRoutes(baseApp) + } + + serve( + { + fetch: baseApp.fetch, + port: port ? Number.parseInt(port as string) : 7070, + }, + (info) => { + c.print({ message: `Listening on http://localhost:${info.port}` }) + } + ) } diff --git a/src/utils/build.test.ts b/src/utils/build.test.ts index 904d323..bfe30d6 100644 --- a/src/utils/build.test.ts +++ b/src/utils/build.test.ts @@ -1,6 +1,7 @@ import type { context, PluginBuild } from 'esbuild' import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Buffer } from 'node:buffer' // Mock dependencies vi.mock('esbuild', () => ({ diff --git a/src/utils/build.ts b/src/utils/build.ts index 0ddfe6d..87e4102 100644 --- a/src/utils/build.ts +++ b/src/utils/build.ts @@ -1,5 +1,6 @@ import * as esbuild from 'esbuild' import type { Hono } from 'hono' +import { Buffer } from 'node:buffer' export interface BuildOptions { external?: string[]