Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ hono request [file] [options]

- `-P, --path <path>` - Request path (default: "/")
- `-X, --method <method>` - HTTP method (default: GET)
- `-d, --data <data>` - Request body data
- `-d, --data <data>` - Request body data (`@file` reads a file, `@-` reads stdin)
- `-H, --header <header>` - Custom headers (can be used multiple times)
- `-w, --watch` - Watch for changes and resend request
- `-o, --output <file>` - Write response body to file instead of stdout
Expand Down Expand Up @@ -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:**
Expand Down
111 changes: 111 additions & 0 deletions src/commands/request/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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
})
})
})
28 changes: 24 additions & 4 deletions src/commands/request/index.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.',
],
}
Expand All @@ -41,7 +45,7 @@ export function requestCommand(program: Command) {
.argument('[file]', 'Path to the Hono app file')
.option('-P, --path <path>', 'Request path', '/')
.option('-X, --method <method>', 'HTTP method', 'GET')
.option('-d, --data <data>', 'Request body data')
.option('-d, --data <data>', 'Request body data (@file reads a file, @- reads stdin)')
.option('-w, --watch', 'Watch for changes and resend request', false)
.option(
'-H, --header <header>',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/utils/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
21 changes: 18 additions & 3 deletions src/utils/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hono> {
let resolveApp: (app: Hono) => void
Expand All @@ -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(),
Expand Down
34 changes: 33 additions & 1 deletion src/utils/load-app.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,6 +14,18 @@ export function getBuildIterator(
watch: boolean,
external: string[] = []
): AsyncGenerator<Hono> {
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

Expand Down Expand Up @@ -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`
}
Loading