diff --git a/README.md b/README.md index 4e9b076..a1de363 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ hono request [file] [options] - `-P, --path ` - Request path (default: "/") - `-X, --method ` - HTTP method (default: GET) -- `-d, --data ` - Request body data +- `-d, --data ` - Request body data (`@file` reads a file, `@-` reads stdin) - `-H, --header
` - Custom headers (can be used multiple times) - `-w, --watch` - Watch for changes and resend request - `-o, --output ` - Write response body to file instead of stdout @@ -97,6 +97,12 @@ hono request -P /api/protected \ # Request with external packages (useful for Node.js native modules) hono request -e pg -e dotenv src/your-app.ts + +# Read the request body from stdin +cat payload.json | hono request -P /api/users -X POST -d @- + +# Read the app code from stdin: `app` is predefined and exported for you +echo 'app.get("/hello", (c) => c.json({ ok: true }))' | hono request - -P /hello ``` **Output:** diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index f1fd0a1..18f097d 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' vi.mock('node:fs', () => ({ existsSync: vi.fn(), realpathSync: vi.fn(), + readFileSync: vi.fn(), })) vi.mock('node:path', () => ({ @@ -33,6 +34,7 @@ describe('requestCommand', () => { const getMockModules = async () => ({ existsSync: vi.mocked((await import('node:fs')).existsSync), realpathSync: vi.mocked((await import('node:fs')).realpathSync), + readFileSync: vi.mocked((await import('node:fs')).readFileSync), resolve: vi.mocked((await import('node:path')).resolve), }) const getMockBuildAndImportApp = async () => @@ -952,4 +954,113 @@ describe('requestCommand', () => { expect(process.exitCode).toBe(1) process.exitCode = undefined }) + + describe('stdin', () => { + it('should read the body from a file with -d @file', async () => { + const mockApp = new Hono() + mockApp.post('/echo', async (c) => c.json({ received: await c.req.text() })) + setupBasicMocks('test-app.js', mockApp) + mockModules.readFileSync.mockReturnValue('{"name":"Alice"}') + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/echo', + '-X', + 'POST', + '-d', + '@body.json', + 'test-app.js', + ]) + + expect(mockModules.readFileSync).toHaveBeenCalledWith('body.json', 'utf-8') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.data.body).toEqual({ received: '{"name":"Alice"}' }) + }) + + it('should read the body from stdin with -d @-', async () => { + const mockApp = new Hono() + mockApp.post('/echo', async (c) => c.json({ received: await c.req.text() })) + setupBasicMocks('test-app.js', mockApp) + mockModules.readFileSync.mockReturnValue('from stdin') + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/echo', + '-X', + 'POST', + '-d', + '@-', + 'test-app.js', + ]) + + expect(mockModules.readFileSync).toHaveBeenCalledWith(0, 'utf-8') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.data.body).toEqual({ received: 'from stdin' }) + }) + + it('should read the app code from stdin with -', async () => { + const mockApp = new Hono() + mockApp.get('/', (c) => c.text('from code')) + mockModules.readFileSync.mockReturnValue('export default app') + mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) + + await program.parseAsync(['node', 'test', 'request', '-', '-P', '/']) + + expect(mockModules.readFileSync).toHaveBeenCalledWith(0, 'utf-8') + expect(mockBuildAndImportApp).toHaveBeenCalledWith( + { code: 'export default app' }, + { external: ['@hono/node-server'] } + ) + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.data.body).toBe('from code') + }) + + it('should wrap stdin code without a default export', async () => { + const mockApp = new Hono() + mockApp.get('/', (c) => c.text('wrapped')) + mockModules.readFileSync.mockReturnValue('app.get("/", (c) => c.text("wrapped"))') + mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) + + await program.parseAsync(['node', 'test', 'request', '-', '-P', '/']) + + expect(mockBuildAndImportApp).toHaveBeenCalledWith( + { + code: + "import { Hono } from 'hono'\n" + + 'const app = new Hono()\n' + + 'app.get("/", (c) => c.text("wrapped"))\n' + + 'export default app\n', + }, + { external: ['@hono/node-server'] } + ) + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.data.body).toBe('wrapped') + }) + + it('should reject - together with -d @-', async () => { + await program.parseAsync(['node', 'test', 'request', '-', '-d', '@-']) + + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.ok).toBe(false) + expect(parsed.error.code).toBe('INVALID_OPTION') + expect(process.exitCode).toBe(1) + process.exitCode = undefined + }) + + it('should reject - together with --watch', async () => { + await program.parseAsync(['node', 'test', 'request', '-', '-w']) + + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) + expect(parsed.ok).toBe(false) + expect(parsed.error.code).toBe('INVALID_OPTION') + expect(process.exitCode).toBe(1) + process.exitCode = undefined + }) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 87957dc..61a44c9 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -1,9 +1,10 @@ import type { Command } from 'commander' import type { Hono } from 'hono' +import { readFileSync } from 'node:fs' import type { CommandAgentContext } from '../../utils/agent-context.js' import { getFilenameFromPath, saveFile } from '../../utils/file.js' -import { getBuildIterator } from '../../utils/load-app.js' -import { handleErrors, printResult } from '../../utils/output.js' +import { getBuildIterator, readStdin } from '../../utils/load-app.js' +import { CliError, handleErrors, printResult } from '../../utils/output.js' export const agentContext: CommandAgentContext = { output: @@ -12,10 +13,13 @@ export const agentContext: CommandAgentContext = { examples: [ 'hono request -P /api/users', `hono request -P /api/users -X POST -d '{"name":"Alice"}'`, - 'hono request -P /image.png -o image.png', + 'cat payload.json | hono request -P /api/users -X POST -d @-', + `echo 'app.get("/hello", (c) => c.json({ ok: true }))' | hono request - -P /hello`, ], notes: [ 'No server needed. The request goes directly to app.request().', + 'Pass - as the file to read the app code from stdin. `app` is predefined and exported for you — write only routes. Code with its own `export default` is used as-is.', + '-d @file reads the body from a file, -d @- reads it from stdin.', 'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.', ], } @@ -41,7 +45,7 @@ export function requestCommand(program: Command) { .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('-d, --data ', 'Request body data (@file reads a file, @- reads stdin)') .option('-w, --watch', 'Watch for changes and resend request', false) .option( '-H, --header
', @@ -70,6 +74,12 @@ export function requestCommand(program: Command) { const path = options.path || '/' const watch = options.watch const external = options.external || [] + if (file === '-' && options.data === '@-') { + throw new CliError('INVALID_OPTION', 'Cannot read both the app and the body from stdin', { + suggestions: ['Pass the app as a file, or the body with -d @file'], + }) + } + options.data = resolveData(options.data) const buildIterator = getBuildIterator(file, watch, external) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) @@ -149,6 +159,16 @@ const handleSaveOutput = async ( } } +const resolveData = (data: string | undefined): string | undefined => { + if (data === undefined || !data.startsWith('@')) { + return data + } + if (data === '@-') { + return readStdin() + } + return readFileSync(data.slice(1), 'utf-8') +} + export async function executeRequest( app: Hono, requestPath: string, diff --git a/src/utils/build.test.ts b/src/utils/build.test.ts index e2877c4..2fccfd3 100644 --- a/src/utils/build.test.ts +++ b/src/utils/build.test.ts @@ -268,4 +268,29 @@ describe('buildAndImportApp', () => { ], }) }) + + it('should build code from stdin', async () => { + const mockApp = new Hono() + const bundledCode = 'export default app;' + + setupBundledCode(bundledCode) + const dataUrl = `data:text/javascript;base64,${Buffer.from(bundledCode).toString('base64')}` + vi.doMock(dataUrl, () => ({ default: mockApp })) + + const buildIterator = buildAndImportApp({ code: 'export default app' }) + const result = (await buildIterator.next()).value + + expect(mockEsbuild).toHaveBeenCalledWith( + expect.objectContaining({ + stdin: { + contents: 'export default app', + resolveDir: process.cwd(), + loader: 'tsx', + sourcefile: '__stdin__.tsx', + }, + }) + ) + expect(mockEsbuild.mock.calls[0][0]).not.toHaveProperty('entryPoints') + expect(result).toBe(mockApp) + }) }) diff --git a/src/utils/build.ts b/src/utils/build.ts index 97452d5..2c9dc55 100644 --- a/src/utils/build.ts +++ b/src/utils/build.ts @@ -9,11 +9,14 @@ export interface BuildOptions { plugins?: Plugin[] } +/** App source: a file path, or code read from stdin */ +export type AppEntry = string | { code: string } + /** - * Build and import a TypeScript/JSX/JS file as an app + * Build and import a TypeScript/JSX/JS app from a file or from code */ export async function* buildAndImportApp( - filePath: string, + entry: AppEntry, options: BuildOptions = {} ): AsyncGenerator { let resolveApp: (app: Hono) => void @@ -26,8 +29,20 @@ export async function* buildAndImportApp( } preparePromise() + const entryConfig = + typeof entry === 'string' + ? { entryPoints: [entry] } + : { + stdin: { + contents: entry.code, + resolveDir: process.cwd(), + loader: 'tsx' as const, + sourcefile: '__stdin__.tsx', + }, + } + const context = await esbuild.context({ - entryPoints: [filePath], + ...entryConfig, sourcemap: options.sourcemap ?? false, sourcesContent: false, sourceRoot: process.cwd(), diff --git a/src/utils/load-app.ts b/src/utils/load-app.ts index 42ef269..60d50dc 100644 --- a/src/utils/load-app.ts +++ b/src/utils/load-app.ts @@ -1,5 +1,5 @@ import type { Hono } from 'hono' -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, realpathSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { buildAndImportApp } from './build.js' import { CliError } from './output.js' @@ -14,6 +14,18 @@ export function getBuildIterator( watch: boolean, external: string[] = [] ): AsyncGenerator { + if (appPath === '-') { + if (watch) { + throw new CliError('INVALID_OPTION', 'Cannot watch the app read from stdin', { + suggestions: ['Pass a file path instead of - when using --watch'], + }) + } + return buildAndImportApp( + { code: wrapCode(readStdin()) }, + { external: ['@hono/node-server', ...external] } + ) + } + let entry: string let resolvedAppPath: string @@ -45,3 +57,23 @@ export function getBuildIterator( sourcemap: true, }) } + +export const readStdin = (): string => { + if (process.stdin.isTTY) { + throw new CliError('MISSING_STDIN', 'No input on stdin', { + suggestions: ['Pipe the app code: cat app.ts | hono request - -P /'], + }) + } + return readFileSync(0, 'utf-8') +} + +/** + * Code from stdin does not need boilerplate. If it has no default + * export, wrap it: `app` is predefined and exported. + */ +export const wrapCode = (code: string): string => { + if (/export\s+default/.test(code)) { + return code + } + return `import { Hono } from 'hono'\nconst app = new Hono()\n${code}\nexport default app\n` +}