From 7ad67e1a13c057c2653163bc3f70e4969422e406 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 6 Dec 2025 15:53:05 +0900 Subject: [PATCH 01/21] feat(request): pretty output Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 92 +++++++++++++++++++++++++++--- src/commands/request/index.ts | 45 ++++++++++++++- src/types/branded-types.ts | 3 + 3 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 src/types/branded-types.ts diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 3790cfd..0045c69 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -21,6 +21,7 @@ import { requestCommand } from './index.js' describe('requestCommand', () => { let program: Command let consoleLogSpy: ReturnType + let consoleWarnSpy: ReturnType let mockModules: any let mockBuildAndImportApp: any @@ -51,6 +52,7 @@ describe('requestCommand', () => { program = new Command() requestCommand(program) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) // Get mocked modules mockModules = { @@ -66,6 +68,7 @@ describe('requestCommand', () => { afterEach(() => { consoleLogSpy.mockRestore() + consoleWarnSpy.mockRestore() vi.restoreAllMocks() }) @@ -90,7 +93,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: JSON.stringify({ message: 'Hello' }, null, 2), headers: { 'content-type': 'application/json' }, }, null, @@ -99,7 +102,7 @@ describe('requestCommand', () => { ) }) - it('should handle GET request to specific file', async () => { + it('should handle GET request to specific file with watch option', async () => { const mockApp = new Hono() mockApp.get('/', (c) => c.json({ message: 'Hello' })) @@ -120,7 +123,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: JSON.stringify({ message: 'Hello' }, null, 2), headers: { 'content-type': 'application/json' }, }, null, @@ -158,7 +161,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 201, - body: '{"received":"test data"}', + body: JSON.stringify({ received: 'test data' }, null, 2), headers: { 'content-type': 'application/json', 'x-custom-header': 'test-value' }, }, null, @@ -195,7 +198,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 200, - body: '{"message":"Default app"}', + body: JSON.stringify({ message: 'Default app' }, null, 2), headers: { 'content-type': 'application/json' }, }, null, @@ -233,7 +236,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"auth":"Bearer token123"}', + body: JSON.stringify({ auth: 'Bearer token123' }, null, 2), headers: { 'content-type': 'application/json' }, }, null, @@ -273,7 +276,11 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"auth":"Bearer token456","userAgent":"TestClient/1.0","custom":"custom-value"}', + body: JSON.stringify( + { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, + null, + 2 + ), headers: { 'content-type': 'application/json' }, }, null, @@ -328,7 +335,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"success":true}', + body: JSON.stringify({ success: true }, null, 2), headers: { 'content-type': 'application/json' }, }, null, @@ -336,4 +343,73 @@ describe('requestCommand', () => { ) ) }) + + it('should handle HTML response', async () => { + const mockApp = new Hono() + const htmlContent = '

Hello World

' + mockApp.get('/html', (c) => c.html(htmlContent)) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/html', 'test-app.js']) + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify( + { + status: 200, + body: htmlContent, + headers: { 'content-type': 'text/html; charset=UTF-8' }, + }, + null, + 2 + ) + ) + }) + + it('should handle XML response', async () => { + const mockApp = new Hono() + const xmlContent = 'Hello' + mockApp.get('/xml', (c) => c.body(xmlContent, 200, { 'Content-Type': 'application/xml' })) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/xml', 'test-app.js']) + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify( + { + status: 200, + body: xmlContent, + headers: { 'content-type': 'application/xml' }, + }, + null, + 2 + ) + ) + }) + + it('should warn on binary PNG response', async () => { + const mockApp = new Hono() + const pngData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]) + mockApp.get('/image.png', (c) => c.body(pngData.buffer, 200, { 'Content-Type': 'image/png' })) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', 'test-app.js']) + expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') + expect(consoleLogSpy).not.toHaveBeenCalled() + }) + + it('should warn on binary PDF response', async () => { + const mockApp = new Hono() + const pdfData = new Uint8Array([37, 80, 68, 70, 45, 49, 46, 55, 0, 0, 0, 0]) + mockApp.get('/document.pdf', (c) => + c.body(pdfData.buffer, 200, { 'Content-Type': 'application/pdf' }) + ) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/document.pdf', 'test-app.js']) + expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') + expect(consoleLogSpy).not.toHaveBeenCalled() + }) + + it('should exclude protocol headers when --exclude is used', async () => { + const mockApp = new Hono() + const jsonBody = { message: 'Success' } + mockApp.get('/data', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/data', '--exclude', 'test-app.js']) + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index cc84a54..6c55860 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander' import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' +import type { JSONData } from '../../types/branded-types.js' import { buildAndImportApp } from '../../utils/build.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -12,6 +13,7 @@ interface RequestOptions { header?: string[] path?: string watch: boolean + exclude: boolean } export function requestCommand(program: Command) { @@ -23,6 +25,7 @@ export function requestCommand(program: Command) { .option('-X, --method ', 'HTTP method', 'GET') .option('-d, --data ', 'Request body data') .option('-w, --watch', 'Watch for changes and resend request', false) + .option('-e, --exclude', 'Exclude protocol response headers in the output', false) .option( '-H, --header
', 'Custom headers', @@ -37,7 +40,23 @@ export function requestCommand(program: Command) { 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)) + const outputBody = parsedResponseBody(result.body) + const buffer = await result.response.clone().arrayBuffer() + if (isBinaryResponse(buffer)) { + console.warn('Binary output can mess up your terminal.') + return + } + if (options.exclude) { + console.log(outputBody) + } else { + console.log( + JSON.stringify( + { status: result.status, body: outputBody, headers: result.headers }, + null, + 2 + ) + ) + } } }) } @@ -77,7 +96,7 @@ export async function executeRequest( app: Hono, requestPath: string, options: RequestOptions -): Promise<{ status: number; body: string; headers: Record }> { +): Promise<{ status: number; body: string; headers: Record; response: Response }> { // Build request const url = new URL(requestPath, 'http://localhost') const requestInit: RequestInit = { @@ -111,11 +130,31 @@ export async function executeRequest( responseHeaders[key] = value }) - const body = await response.text() + const body = await response.clone().text() return { status: response.status, body, headers: responseHeaders, + response: response, } } + +const parsedResponseBody = (responseBody: string): JSONData | string => { + try { + return JSON.stringify(JSON.parse(responseBody), null, 2) as JSONData + } catch { + return responseBody + } +} + +const isBinaryResponse = (buffer: ArrayBuffer): boolean => { + const view = new Uint8Array(buffer) + const len = Math.min(view.length, 2000) + for (let i = 0; i < len; i++) { + if (view[i] === 0) { + return true + } + } + return false +} diff --git a/src/types/branded-types.ts b/src/types/branded-types.ts new file mode 100644 index 0000000..b51f653 --- /dev/null +++ b/src/types/branded-types.ts @@ -0,0 +1,3 @@ +declare const brandSymbol: unique symbol + +export type JSONData = string & { readonly [brandSymbol]: 'jsonData' } From 53f8e13f86aed9dd47ec991829ca8b95fa64766e Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 6 Dec 2025 16:28:08 +0900 Subject: [PATCH 02/21] feat(request): pretty output fix test Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 22 ++++++++++------------ src/commands/request/index.ts | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 0045c69..50dc739 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -93,7 +93,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: JSON.stringify({ message: 'Hello' }, null, 2), + body: { message: 'Hello' }, headers: { 'content-type': 'application/json' }, }, null, @@ -123,7 +123,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: JSON.stringify({ message: 'Hello' }, null, 2), + body: { message: 'Hello' }, headers: { 'content-type': 'application/json' }, }, null, @@ -161,7 +161,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 201, - body: JSON.stringify({ received: 'test data' }, null, 2), + body: { received: 'test data' }, headers: { 'content-type': 'application/json', 'x-custom-header': 'test-value' }, }, null, @@ -198,7 +198,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 200, - body: JSON.stringify({ message: 'Default app' }, null, 2), + body: { message: 'Default app' }, headers: { 'content-type': 'application/json' }, }, null, @@ -236,7 +236,9 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: JSON.stringify({ auth: 'Bearer token123' }, null, 2), + body: { + auth: 'Bearer token123', + }, headers: { 'content-type': 'application/json' }, }, null, @@ -276,11 +278,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: JSON.stringify( - { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, - null, - 2 - ), + body: { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, headers: { 'content-type': 'application/json' }, }, null, @@ -335,7 +333,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: JSON.stringify({ success: true }, null, 2), + body: { success: true }, headers: { 'content-type': 'application/json' }, }, null, @@ -410,6 +408,6 @@ describe('requestCommand', () => { mockApp.get('/data', (c) => c.json(jsonBody)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', '--exclude', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) + expect(consoleLogSpy).toHaveBeenCalledWith(jsonBody) }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 6c55860..e1f8981 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -142,7 +142,7 @@ export async function executeRequest( const parsedResponseBody = (responseBody: string): JSONData | string => { try { - return JSON.stringify(JSON.parse(responseBody), null, 2) as JSONData + return JSON.parse(responseBody) as JSONData } catch { return responseBody } From cc5c2a36d1737df9c06bc253ef4e7596462c5840 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 6 Dec 2025 16:44:30 +0900 Subject: [PATCH 03/21] feat(request): pretty output fix function name `parsedResponseBody` Signed-off-by: ysknsid25 --- src/commands/request/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index e1f8981..a44fac4 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -40,7 +40,7 @@ export function requestCommand(program: Command) { const buildIterator = getBuildIterator(file, watch) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) - const outputBody = parsedResponseBody(result.body) + const outputBody = parseResponseBody(result.body) const buffer = await result.response.clone().arrayBuffer() if (isBinaryResponse(buffer)) { console.warn('Binary output can mess up your terminal.') @@ -140,7 +140,7 @@ export async function executeRequest( } } -const parsedResponseBody = (responseBody: string): JSONData | string => { +const parseResponseBody = (responseBody: string): JSONData | string => { try { return JSON.parse(responseBody) as JSONData } catch { From d6aa81585684fe3a0580958169955539be4d1252 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 6 Dec 2025 16:46:31 +0900 Subject: [PATCH 04/21] feat(request): pretty output fix README Signed-off-by: ysknsid25 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fc87617..db99d68 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, --exclude` - Exclude protocol response headers in the output **Examples:** From dd22628682458e499918c8f94d4b37daa5280519 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 6 Dec 2025 20:22:30 +0900 Subject: [PATCH 05/21] feat(request): pretty output fix output format when direct`exclude` Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 2 +- src/commands/request/index.ts | 31 +++++++++++++++++++++++------- src/types/branded-types.ts | 3 --- 3 files changed, 25 insertions(+), 11 deletions(-) delete mode 100644 src/types/branded-types.ts diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 50dc739..c47dba6 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -408,6 +408,6 @@ describe('requestCommand', () => { mockApp.get('/data', (c) => c.json(jsonBody)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', '--exclude', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith(jsonBody) + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index a44fac4..4ca5fe5 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -2,7 +2,6 @@ import type { Command } from 'commander' import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' -import type { JSONData } from '../../types/branded-types.js' import { buildAndImportApp } from '../../utils/build.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -40,7 +39,11 @@ export function requestCommand(program: Command) { const buildIterator = getBuildIterator(file, watch) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) - const outputBody = parseResponseBody(result.body) + const outputBody = formatResponseBody( + result.body, + result.headers['content-type'], + options.exclude + ) const buffer = await result.response.clone().arrayBuffer() if (isBinaryResponse(buffer)) { console.warn('Binary output can mess up your terminal.') @@ -140,11 +143,25 @@ export async function executeRequest( } } -const parseResponseBody = (responseBody: string): JSONData | string => { - try { - return JSON.parse(responseBody) as JSONData - } catch { - return responseBody +const formatResponseBody = ( + responseBody: string, + contentType: string | undefined, + excludeOption: boolean +): string => { + switch (contentType) { + case 'application/json': // expect c.json(data) response + try { + const parsedJSON = JSON.parse(responseBody) + if (excludeOption) { + return JSON.stringify(parsedJSON, null, 2) + } + return parsedJSON + } catch { + console.error('Response indicated JSON content type but failed to parse JSON.') + return responseBody + } + default: + return responseBody } } diff --git a/src/types/branded-types.ts b/src/types/branded-types.ts deleted file mode 100644 index b51f653..0000000 --- a/src/types/branded-types.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare const brandSymbol: unique symbol - -export type JSONData = string & { readonly [brandSymbol]: 'jsonData' } From 9c27842ded34ad5b3840b6eee9b134e9041c388f Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Mon, 5 Jan 2026 22:17:37 +0900 Subject: [PATCH 06/21] feat(request): `-o ` and `-O` option Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 174 +++++++++++++++++++++++++++++ src/commands/request/index.ts | 76 +++++++++++-- src/utils/file.test.ts | 72 ++++++++++++ src/utils/file.ts | 60 ++++++++++ 4 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 src/utils/file.test.ts create mode 100644 src/utils/file.ts diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index c47dba6..2f59f5b 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -18,6 +18,11 @@ vi.mock('../../utils/build.js', () => ({ import { requestCommand } from './index.js' +vi.mock('../../utils/file.js', () => ({ + getFilenameFromPath: vi.fn(), + saveFile: vi.fn(), +})) + describe('requestCommand', () => { let program: Command let consoleLogSpy: ReturnType @@ -410,4 +415,173 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/data', '--exclude', 'test-app.js']) expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) }) + + it('should save JSON response to specified file with -o option', async () => { + const mockApp = new Hono() + const jsonBody = { message: 'Saved JSON' } + mockApp.get('/save-json', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + + const outputPath = 'output.json' + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/save-json', + '-o', + outputPath, + 'test-app.js', + ]) + + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(JSON.stringify({ status: 200, body: jsonBody, headers: { 'content-type': 'application/json' } }, null, 2)).buffer, + outputPath + ) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + }) + + it('should save binary response to specified file with -o option', async () => { + const mockApp = new Hono() + const binaryData = new Uint8Array([1, 2, 3, 4, 5]).buffer + mockApp.get('/save-binary', (c) => c.body(binaryData, 200, { 'Content-Type': 'application/octet-stream' })) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + + const outputPath = 'output.bin' + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/save-binary', + '-o', + outputPath, + 'test-app.js', + ]) + + expect(mockSaveFile).toHaveBeenCalledWith(binaryData, outputPath) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + }) + + it('should save response to remote-named file with -O option', async () => { + const mockApp = new Hono() + const htmlContent = 'Hello' + mockApp.get('/index.html', (c) => c.html(htmlContent)) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + mockGetFilenameFromPath.mockReturnValue('index.html') + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/index.html', + '-O', + 'test-app.js', + ]) + + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html') + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(JSON.stringify({ status: 200, body: htmlContent, headers: { 'content-type': 'text/html; charset=UTF-8' } }, null, 2)).buffer, + 'index.html' + ) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index.html`) + }) + + it('should save binary response to remote-named file with -O option', async () => { + const mockApp = new Hono() + const pngData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]).buffer + mockApp.get('/image.png', (c) => c.body(pngData, 200, { 'Content-Type': 'image/png' })) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + mockGetFilenameFromPath.mockReturnValue('image.png') + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/image.png', + '-O', + 'test-app.js', + ]) + + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png') + expect(mockSaveFile).toHaveBeenCalledWith(pngData, 'image.png') + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to image.png`) + }) + + it('should prioritize -o over -O when both are present', async () => { + const mockApp = new Hono() + const textContent = 'Text content' + mockApp.get('/text.txt', (c) => c.text(textContent)) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + + const outputPath = 'custom-output.txt' + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/text.txt', + '-o', + outputPath, + '-O', + 'test-app.js', + ]) + + expect(mockGetFilenameFromPath).not.toHaveBeenCalled() + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(JSON.stringify({ status: 200, body: textContent, headers: { 'content-type': 'text/plain;charset=UTF-8' } }, null, 2)).buffer, + outputPath + ) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + }) + + it('should exclude protocol headers and save with -o option', async () => { + const mockApp = new Hono() + const jsonBody = { data: 'filtered' } + mockApp.get('/filtered-data', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + + const outputPath = 'filtered-output.json' + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/filtered-data', + '--exclude', + '-o', + outputPath, + 'test-app.js', + ]) + + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(JSON.stringify(jsonBody, null, 2)).buffer, + outputPath + ) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 4ca5fe5..d31efcb 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -3,6 +3,7 @@ import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' import { buildAndImportApp } from '../../utils/build.js' +import { getFilenameFromPath, saveFile } from '../../utils/file.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -13,6 +14,8 @@ interface RequestOptions { path?: string watch: boolean exclude: boolean + output?: string + remoteName: boolean } export function requestCommand(program: Command) { @@ -33,7 +36,10 @@ export function requestCommand(program: Command) { }, [] as string[] ) + .option('-o, --output ', 'Write to file instead of stdout') + .option('-O, --remote-name', 'Write output to file named as remote file', false) .action(async (file: string | undefined, options: RequestOptions) => { + const doSaveFile = options.output || options.remoteName const path = options.path || '/' const watch = options.watch const buildIterator = getBuildIterator(file, watch) @@ -45,25 +51,73 @@ export function requestCommand(program: Command) { options.exclude ) const buffer = await result.response.clone().arrayBuffer() - if (isBinaryResponse(buffer)) { + const isBinaryData = isBinaryResponse(buffer) + if (isBinaryData && !doSaveFile) { console.warn('Binary output can mess up your terminal.') return } - if (options.exclude) { - console.log(outputBody) - } else { - console.log( - JSON.stringify( - { status: result.status, body: outputBody, headers: result.headers }, - null, - 2 - ) - ) + + const outputData = getOutputData( + buffer, + outputBody, + isBinaryData, + options, + result.status, + result.headers + ) + if (!isBinaryData) { + console.log(outputData) + } + + if (doSaveFile) { + await handleSaveOutput(outputData, path, options) } } }) } +function getOutputData( + buffer: ArrayBuffer, + outputBody: string, + isBinaryData: boolean, + options: RequestOptions, + status: number, + headers: Record +): string | ArrayBuffer { + if (isBinaryData) { + return buffer + } else { + if (options.exclude) { + return outputBody + } else { + return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) + } + } +} + +async function handleSaveOutput( + saveData: string | ArrayBuffer, + requestPath: string, + options: RequestOptions +): Promise { + let filepath: string + if (options.output) { + filepath = options.output + } else { + filepath = getFilenameFromPath(requestPath) + } + try { + await saveFile( + typeof saveData === 'string' ? new TextEncoder().encode(saveData).buffer : saveData, + filepath + ) + console.log(`Saved response to ${filepath}`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + console.error(`Error saving file: ${error.message}`) + } +} + export function getBuildIterator( appPath: string | undefined, watch: boolean diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 0000000..9dacf0b --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { getFilenameFromPath, saveFile } from './file' + +describe('getFilenameFromPath', () => { + it('should extract filename from simple path', () => { + expect(getFilenameFromPath('/foo/bar.txt')).toBe('bar.txt') + }) + + it('should extract filename from path with query params', () => { + expect(getFilenameFromPath('/foo/bar.txt?baz=qux')).toBe('bar.txt') + }) + + it('should extract filename from path ending with slash', () => { + expect(getFilenameFromPath('/foo/bar/')).toBe('bar') + }) + + it('should extract filename from path with hostname', () => { + expect(getFilenameFromPath('http://example.com:8080/foo/bar.txt')).toBe('bar.txt') + }) +}) + +describe('saveFile', () => { + const tmpDir = join(tmpdir(), 'hono-cli-file-test-' + Date.now()) + + beforeEach(() => { + if (!existsSync(tmpDir)) { + mkdirSync(tmpDir) + } + }) + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('should save file correctly', async () => { + const filename = 'test.txt' + const filepath = join(tmpDir, filename) + const content = new TextEncoder().encode('Hello, World!') + + await saveFile(content.buffer, filepath) + + expect(existsSync(filepath)).toBe(true) + expect(readFileSync(filepath, 'utf-8')).toBe('Hello, World!') + }) + + it('should throw error if file already exists', async () => { + const filename = 'existing.txt' + const filepath = join(tmpDir, filename) + const content = new TextEncoder().encode('foo') + + // Create dummy file + await saveFile(content.buffer, filepath) + + await expect(saveFile(content.buffer, filepath)).rejects.toThrow( + `File ${filepath} already exists.` + ) + }) + + it('should throw error if directory does not exist', async () => { + const filepath = join(tmpDir, 'non/existent/dir/file.txt') + const content = new TextEncoder().encode('foo') + + await expect(saveFile(content.buffer, filepath)).rejects.toThrow( + `Directory ${resolve(tmpDir, 'non/existent/dir')} does not exist.` + ) + }) +}) diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..98da9d9 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,60 @@ +import { existsSync, createWriteStream } from 'node:fs' +import { dirname, basename } from 'node:path' + +export const getFilenameFromPath = (path: string): string => { + // We use 'http://localhost' as a base because 'path' is often a relative path (e.g., '/users/123'). + // The URL constructor requires a base URL for relative paths to parse them correctly. + // If 'path' is an absolute URL, the base argument is ignored. + const url = new URL(path, 'http://localhost') + const pathname = url.pathname + const name = basename(pathname) + + return name +} + +export const saveFile = async (buffer: ArrayBuffer, filepath: string): Promise => { + if (existsSync(filepath)) { + throw new Error(`File ${filepath} already exists.`) + } + + const dir = dirname(filepath) + if (!existsSync(dir)) { + throw new Error(`Directory ${dir} does not exist.`) + } + + const totalBytes = buffer.byteLength + const view = new Uint8Array(buffer) + const chunkSize = 1024 * 64 // 64KB + let savedBytes = 0 + + const stream = createWriteStream(filepath) + + return new Promise((resolve, reject) => { + stream.on('error', (err) => reject(err)) + + const writeChunk = (index: number) => { + if (index >= totalBytes) { + stream.end(() => { + resolve() + }) + return + } + + const end = Math.min(index + chunkSize, totalBytes) + const chunk = view.slice(index, end) + + stream.write(chunk, (err) => { + if (err) { + stream.destroy(err) + reject(err) + return + } + savedBytes += chunk.length + console.log(`Saved ${savedBytes} of ${totalBytes} bytes`) + writeChunk(end) + }) + } + + writeChunk(0) + }) +} From 97a29b4d2c4e52d506413e675e7723a8c9589a3d Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Mon, 5 Jan 2026 22:19:39 +0900 Subject: [PATCH 07/21] format Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 70 +++++++++++++++++++----------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 2f59f5b..05e8d3d 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -438,7 +438,13 @@ describe('requestCommand', () => { ]) expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(JSON.stringify({ status: 200, body: jsonBody, headers: { 'content-type': 'application/json' } }, null, 2)).buffer, + new TextEncoder().encode( + JSON.stringify( + { status: 200, body: jsonBody, headers: { 'content-type': 'application/json' } }, + null, + 2 + ) + ).buffer, outputPath ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) @@ -447,7 +453,9 @@ describe('requestCommand', () => { it('should save binary response to specified file with -o option', async () => { const mockApp = new Hono() const binaryData = new Uint8Array([1, 2, 3, 4, 5]).buffer - mockApp.get('/save-binary', (c) => c.body(binaryData, 200, { 'Content-Type': 'application/octet-stream' })) + mockApp.get('/save-binary', (c) => + c.body(binaryData, 200, { 'Content-Type': 'application/octet-stream' }) + ) setupBasicMocks('test-app.js', mockApp) const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) @@ -477,22 +485,26 @@ describe('requestCommand', () => { const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) mockSaveFile.mockResolvedValue(undefined) - const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + const mockGetFilenameFromPath = vi.mocked( + (await import('../../utils/file.js')).getFilenameFromPath + ) mockGetFilenameFromPath.mockReturnValue('index.html') - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/index.html', - '-O', - 'test-app.js', - ]) + await program.parseAsync(['node', 'test', 'request', '-P', '/index.html', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html') expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(JSON.stringify({ status: 200, body: htmlContent, headers: { 'content-type': 'text/html; charset=UTF-8' } }, null, 2)).buffer, + new TextEncoder().encode( + JSON.stringify( + { + status: 200, + body: htmlContent, + headers: { 'content-type': 'text/html; charset=UTF-8' }, + }, + null, + 2 + ) + ).buffer, 'index.html' ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index.html`) @@ -506,18 +518,12 @@ describe('requestCommand', () => { const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) mockSaveFile.mockResolvedValue(undefined) - const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + const mockGetFilenameFromPath = vi.mocked( + (await import('../../utils/file.js')).getFilenameFromPath + ) mockGetFilenameFromPath.mockReturnValue('image.png') - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/image.png', - '-O', - 'test-app.js', - ]) + await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png') expect(mockSaveFile).toHaveBeenCalledWith(pngData, 'image.png') @@ -526,13 +532,15 @@ describe('requestCommand', () => { it('should prioritize -o over -O when both are present', async () => { const mockApp = new Hono() - const textContent = 'Text content' + const textContent = 'Text content' mockApp.get('/text.txt', (c) => c.text(textContent)) setupBasicMocks('test-app.js', mockApp) const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) mockSaveFile.mockResolvedValue(undefined) - const mockGetFilenameFromPath = vi.mocked((await import('../../utils/file.js')).getFilenameFromPath) + const mockGetFilenameFromPath = vi.mocked( + (await import('../../utils/file.js')).getFilenameFromPath + ) const outputPath = 'custom-output.txt' @@ -550,7 +558,17 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).not.toHaveBeenCalled() expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(JSON.stringify({ status: 200, body: textContent, headers: { 'content-type': 'text/plain;charset=UTF-8' } }, null, 2)).buffer, + new TextEncoder().encode( + JSON.stringify( + { + status: 200, + body: textContent, + headers: { 'content-type': 'text/plain;charset=UTF-8' }, + }, + null, + 2 + ) + ).buffer, outputPath ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) From 51b0190ae4acf9d7065b91265e095dd01e68dae0 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Tue, 6 Jan 2026 22:34:23 +0900 Subject: [PATCH 08/21] feat(request): `-J` and `--json` option Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 95 +++++++++++++----------------- src/commands/request/index.ts | 20 +++---- 2 files changed, 50 insertions(+), 65 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 05e8d3d..1115ac9 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -77,6 +77,24 @@ describe('requestCommand', () => { vi.restoreAllMocks() }) + it('should json request body output when default', async () => { + const mockApp = new Hono() + const jsonBody = { message: 'Success' } + mockApp.get('/data', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) + }) + + it('should text request body output when default', async () => { + const mockApp = new Hono() + const text = 'Hello, World!' + mockApp.get('/data', (c) => c.text(text)) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) + expect(consoleLogSpy).toHaveBeenCalledWith(text) + }) + it('should handle GET request to specific file', async () => { const mockApp = new Hono() mockApp.get('/', (c) => c.json({ message: 'Hello' })) @@ -84,7 +102,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js']) + await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js', '-J']) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -114,7 +132,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js']) + await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js', '--json']) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -158,6 +176,7 @@ describe('requestCommand', () => { '-d', 'test data', 'test-app.js', + '-J', ]) // Verify resolve was called with correct arguments @@ -193,7 +212,7 @@ describe('requestCommand', () => { }) mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) - await program.parseAsync(['node', 'test', 'request']) + await program.parseAsync(['node', 'test', 'request', '-J']) // Verify resolve was called with correct arguments for default candidates expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.ts') @@ -235,6 +254,7 @@ describe('requestCommand', () => { '-H', 'Authorization: Bearer token123', 'test-app.js', + '-J', ]) expect(consoleLogSpy).toHaveBeenCalledWith( @@ -277,6 +297,7 @@ describe('requestCommand', () => { '-H', 'X-Custom-Header: custom-value', 'test-app.js', + '-J', ]) expect(consoleLogSpy).toHaveBeenCalledWith( @@ -302,7 +323,15 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/api/noheader', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/api/noheader', + 'test-app.js', + '-J', + ]) // Should not include any custom headers, only default ones const output = consoleLogSpy.mock.calls[0][0] as string @@ -331,6 +360,7 @@ describe('requestCommand', () => { '-H', 'ValidHeader: value', 'test-app.js', + '-J', ]) // Should still work, malformed header is ignored @@ -353,17 +383,7 @@ describe('requestCommand', () => { mockApp.get('/html', (c) => c.html(htmlContent)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/html', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: htmlContent, - headers: { 'content-type': 'text/html; charset=UTF-8' }, - }, - null, - 2 - ) - ) + expect(consoleLogSpy).toHaveBeenCalledWith(htmlContent) }) it('should handle XML response', async () => { @@ -372,17 +392,7 @@ describe('requestCommand', () => { mockApp.get('/xml', (c) => c.body(xmlContent, 200, { 'Content-Type': 'application/xml' })) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/xml', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: xmlContent, - headers: { 'content-type': 'application/xml' }, - }, - null, - 2 - ) - ) + expect(consoleLogSpy).toHaveBeenCalledWith(xmlContent) }) it('should warn on binary PNG response', async () => { @@ -407,15 +417,6 @@ describe('requestCommand', () => { expect(consoleLogSpy).not.toHaveBeenCalled() }) - it('should exclude protocol headers when --exclude is used', async () => { - const mockApp = new Hono() - const jsonBody = { message: 'Success' } - mockApp.get('/data', (c) => c.json(jsonBody)) - setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/data', '--exclude', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) - }) - it('should save JSON response to specified file with -o option', async () => { const mockApp = new Hono() const jsonBody = { message: 'Saved JSON' } @@ -438,13 +439,7 @@ describe('requestCommand', () => { ]) expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode( - JSON.stringify( - { status: 200, body: jsonBody, headers: { 'content-type': 'application/json' } }, - null, - 2 - ) - ).buffer, + new TextEncoder().encode(JSON.stringify(jsonBody)).buffer, outputPath ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) @@ -494,17 +489,7 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html') expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode( - JSON.stringify( - { - status: 200, - body: htmlContent, - headers: { 'content-type': 'text/html; charset=UTF-8' }, - }, - null, - 2 - ) - ).buffer, + new TextEncoder().encode(htmlContent).buffer, 'index.html' ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index.html`) @@ -554,6 +539,7 @@ describe('requestCommand', () => { outputPath, '-O', 'test-app.js', + '-J', ]) expect(mockGetFilenameFromPath).not.toHaveBeenCalled() @@ -574,7 +560,7 @@ describe('requestCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) - it('should exclude protocol headers and save with -o option', async () => { + it('should protocol headers and save when default', async () => { const mockApp = new Hono() const jsonBody = { data: 'filtered' } mockApp.get('/filtered-data', (c) => c.json(jsonBody)) @@ -590,7 +576,6 @@ describe('requestCommand', () => { 'request', '-P', '/filtered-data', - '--exclude', '-o', outputPath, 'test-app.js', diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index d31efcb..852fee8 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -13,7 +13,7 @@ interface RequestOptions { header?: string[] path?: string watch: boolean - exclude: boolean + json: boolean output?: string remoteName: boolean } @@ -27,7 +27,7 @@ export function requestCommand(program: Command) { .option('-X, --method ', 'HTTP method', 'GET') .option('-d, --data ', 'Request body data') .option('-w, --watch', 'Watch for changes and resend request', false) - .option('-e, --exclude', 'Exclude protocol response headers in the output', false) + .option('-J, --json', 'Output response as JSON', false) .option( '-H, --header
', 'Custom headers', @@ -48,7 +48,7 @@ export function requestCommand(program: Command) { const outputBody = formatResponseBody( result.body, result.headers['content-type'], - options.exclude + options.json ) const buffer = await result.response.clone().arrayBuffer() const isBinaryData = isBinaryResponse(buffer) @@ -87,10 +87,10 @@ function getOutputData( if (isBinaryData) { return buffer } else { - if (options.exclude) { - return outputBody - } else { + if (options.json) { return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) + } else { + return outputBody } } } @@ -200,16 +200,16 @@ export async function executeRequest( const formatResponseBody = ( responseBody: string, contentType: string | undefined, - excludeOption: boolean + jsonOption: boolean ): string => { switch (contentType) { case 'application/json': // expect c.json(data) response try { const parsedJSON = JSON.parse(responseBody) - if (excludeOption) { - return JSON.stringify(parsedJSON, null, 2) + if (jsonOption) { + return parsedJSON } - return parsedJSON + return JSON.stringify(parsedJSON, null, 2) } catch { console.error('Response indicated JSON content type but failed to parse JSON.') return responseBody From 248825f4eeb00bf3ccd344c204ba212d96e724db Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 7 Jan 2026 17:12:46 +0900 Subject: [PATCH 09/21] feat(request): `-i` and `-I` option Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 55 ++++++++++++++++++++++++++++++ src/commands/request/index.ts | 17 ++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 1115ac9..08fefaf 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -587,4 +587,59 @@ describe('requestCommand', () => { ) expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) + + it('should include protocol and headers with --include option', async () => { + const mockApp = new Hono() + const textBody = 'Hello from Hono!' + mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'IncludeValue' })) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', 'test-app.js']) + + const expectedOutput = [ + 'STATUS 200', + 'content-type: text/plain; charset=UTF-8', + 'x-custom-header: IncludeValue', + '', + textBody, + ].join('\n') + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) + + it('should only show protocol and headers with --head option', async () => { + const mockApp = new Hono() + const textBody = 'Hello from Hono!' + mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'HeadValue' })) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-I', 'test-app.js']) + + const expectedOutput = [ + 'STATUS 200', + 'content-type: text/plain; charset=UTF-8', + 'x-custom-header: HeadValue', + '', + ].join('\n') + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) + + it('should prioritize --head over --include when both are present', async () => { + const mockApp = new Hono() + const textBody = 'Hello from Hono!' + mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'PrioritizeValue' })) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', '-I', 'test-app.js']) + + const expectedOutput = [ + 'STATUS 200', + 'content-type: text/plain; charset=UTF-8', + 'x-custom-header: PrioritizeValue', + '', + ].join('\n') + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 852fee8..2a26d73 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -16,6 +16,8 @@ interface RequestOptions { json: boolean output?: string remoteName: boolean + include: boolean + head: boolean } export function requestCommand(program: Command) { @@ -38,6 +40,8 @@ export function requestCommand(program: Command) { ) .option('-o, --output ', 'Write to file instead of stdout') .option('-O, --remote-name', 'Write output to file named as remote file', false) + .option('-i, --include', 'Include protocol and headers in the output', false) + .option('-I, --head', 'Show only protocol and headers in the output', false) .action(async (file: string | undefined, options: RequestOptions) => { const doSaveFile = options.output || options.remoteName const path = options.path || '/' @@ -87,7 +91,18 @@ function getOutputData( if (isBinaryData) { return buffer } else { - if (options.json) { + const headerLines: string[] = [] + headerLines.push(`STATUS ${status}`) + for (const key in headers) { + headerLines.push(`${key}: ${headers[key]}`) + } + const headerOutput = headerLines.join('\n') + + if (options.head) { + return headerOutput + '\n' + } else if (options.include) { + return headerOutput + '\n\n' + outputBody + } else if (options.json) { return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) } else { return outputBody From d0843ae1d7e430be1a09dc71d7c25fc3b15dc1ea Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 7 Jan 2026 17:18:09 +0900 Subject: [PATCH 10/21] feat(request): update README.md Signed-off-by: ysknsid25 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index db99d68..c67cf7f 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,11 @@ 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, --exclude` - Exclude protocol response headers in the output +- `-J, --json` - Output response as JSON +- `-o, --output ` - Write to file instead of stdout +- `-O, --remote-name` - Write output to file named as remote file +- `-i, --include` - Include protocol and headers in the output +- `-I, --head` - Show only protocol and headers in the output **Examples:** From 667d7f843a087dfa8523bd10f95b9598cdf56f78 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 7 Jan 2026 17:22:39 +0900 Subject: [PATCH 11/21] feat(request): refactor function getOutputData Signed-off-by: ysknsid25 --- src/commands/request/index.ts | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 2a26d73..bed76e4 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -90,24 +90,25 @@ function getOutputData( ): string | ArrayBuffer { if (isBinaryData) { return buffer - } else { - const headerLines: string[] = [] - headerLines.push(`STATUS ${status}`) - for (const key in headers) { - headerLines.push(`${key}: ${headers[key]}`) - } - const headerOutput = headerLines.join('\n') - - if (options.head) { - return headerOutput + '\n' - } else if (options.include) { - return headerOutput + '\n\n' + outputBody - } else if (options.json) { - return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) - } else { - return outputBody - } } + + const headerLines: string[] = [] + headerLines.push(`STATUS ${status}`) + for (const key in headers) { + headerLines.push(`${key}: ${headers[key]}`) + } + const headerOutput = headerLines.join('\n') + if (options.head) { + return headerOutput + '\n' + } + if (options.include) { + return headerOutput + '\n\n' + outputBody + } + + if (options.json) { + return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) + } + return outputBody } async function handleSaveOutput( From da22238f9075636d4f50f92235e6fda713d38e1e Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Tue, 13 Jan 2026 19:16:59 +0900 Subject: [PATCH 12/21] feat(request): Pretty output resolve [object Object] when console.log to terminal Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 27 +++++++++++++++++++++++++++ src/commands/request/index.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 08fefaf..04f5300 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -642,4 +642,31 @@ describe('requestCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) }) + + it('should display JSON body correctly with --json and --include options', async () => { + const mockApp = new Hono() + const jsonBody = { message: 'Hello JSON' } + mockApp.get('/json-data', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/json-data', + '-J', + '-i', + 'test-app.js', + ]) + + const expectedOutput = [ + 'STATUS 200', + 'content-type: application/json', + '', + JSON.stringify(jsonBody, null, 2), + ].join('\n') + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index bed76e4..8ef32aa 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -52,7 +52,7 @@ export function requestCommand(program: Command) { const outputBody = formatResponseBody( result.body, result.headers['content-type'], - options.json + options.json && !options.include ) const buffer = await result.response.clone().arrayBuffer() const isBinaryData = isBinaryResponse(buffer) From 8b66289c66d4f290d9c5384959d40a7d137d878a Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:19:21 +0900 Subject: [PATCH 13/21] feat(request): Pretty output fix return to continue Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 34 ++++++++++++++++++++++++++++++ src/commands/request/index.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 04f5300..1bf82d2 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -417,6 +417,40 @@ describe('requestCommand', () => { expect(consoleLogSpy).not.toHaveBeenCalled() }) + it('should continue to next build when binary output is detected', async () => { + const mockApp1 = new Hono() + const pngData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]) + mockApp1.get('/resource', (c) => c.body(pngData.buffer, 200, { 'Content-Type': 'image/png' })) + + const mockApp2 = new Hono() + const text = 'Hello, World!' + mockApp2.get('/resource', (c) => c.text(text)) + + const iterator = { + next: vi + .fn() + .mockResolvedValueOnce({ value: mockApp1, done: false }) + .mockResolvedValueOnce({ value: mockApp2, done: false }) + .mockResolvedValueOnce({ value: undefined, done: true }), + return: vi.fn().mockResolvedValue({ value: undefined, done: true }), + [Symbol.asyncIterator]() { + return this + }, + } + mockBuildAndImportApp.mockReturnValue(iterator) + + mockModules.existsSync.mockReturnValue(true) + mockModules.realpathSync.mockReturnValue('test-app.js') + mockModules.resolve.mockImplementation((cwd: string, path: string) => { + return `${cwd}/${path}` + }) + + await program.parseAsync(['node', 'test', 'request', '-P', '/resource', '-w', 'test-app.js']) + + expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') + expect(consoleLogSpy).toHaveBeenCalledWith(text) + }) + it('should save JSON response to specified file with -o option', async () => { const mockApp = new Hono() const jsonBody = { message: 'Saved JSON' } diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 8ef32aa..e4f0b51 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -58,7 +58,7 @@ export function requestCommand(program: Command) { const isBinaryData = isBinaryResponse(buffer) if (isBinaryData && !doSaveFile) { console.warn('Binary output can mess up your terminal.') - return + continue } const outputData = getOutputData( From ad3fd91c52835f1655f932c992ce9ef648f03045 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:23:26 +0900 Subject: [PATCH 14/21] feat(request): `-J` and `--json` option To be able to handle `application/json; charset=utf-8` Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 33 ++++++++++++++++++++++++++++++ src/commands/request/index.ts | 22 +++++++++----------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 1bf82d2..ca98675 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -155,6 +155,39 @@ describe('requestCommand', () => { ) }) + it('should handle JSON response with charset in Content-Type', async () => { + const mockApp = new Hono() + const jsonBody = { message: 'Hello JSON with Charset' } + mockApp.get('/json-charset', (c) => + c.body(JSON.stringify(jsonBody), 200, { + 'Content-Type': 'application/json; charset=utf-8', + }) + ) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/json-charset', + '-J', + 'test-app.js', + ]) + + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify( + { + status: 200, + body: jsonBody, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }, + null, + 2 + ) + ) + }) + it('should handle POST request with data', async () => { const mockApp = new Hono() mockApp.post('/data', async (c) => { diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index e4f0b51..8913329 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -218,21 +218,19 @@ const formatResponseBody = ( contentType: string | undefined, jsonOption: boolean ): string => { - switch (contentType) { - case 'application/json': // expect c.json(data) response - try { - const parsedJSON = JSON.parse(responseBody) - if (jsonOption) { - return parsedJSON - } - return JSON.stringify(parsedJSON, null, 2) - } catch { - console.error('Response indicated JSON content type but failed to parse JSON.') - return responseBody + if (contentType && contentType.indexOf('application/json') !== -1) { + try { + const parsedJSON = JSON.parse(responseBody) + if (jsonOption) { + return parsedJSON } - default: + return JSON.stringify(parsedJSON, null, 2) + } catch { + console.error('Response indicated JSON content type but failed to parse JSON.') return responseBody + } } + return responseBody } const isBinaryResponse = (buffer: ArrayBuffer): boolean => { From 2db37324d0320cc223c694d8029276edc2556860 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:28:15 +0900 Subject: [PATCH 15/21] feat(request): Pretty output show more rich http header Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 22 +++++++++++----------- src/commands/request/index.ts | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index ca98675..39f2593 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -664,9 +664,9 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', 'test-app.js']) const expectedOutput = [ - 'STATUS 200', - 'content-type: text/plain; charset=UTF-8', - 'x-custom-header: IncludeValue', + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: IncludeValue', '', textBody, ].join('\n') @@ -683,9 +683,9 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-I', 'test-app.js']) const expectedOutput = [ - 'STATUS 200', - 'content-type: text/plain; charset=UTF-8', - 'x-custom-header: HeadValue', + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: HeadValue', '', ].join('\n') @@ -701,9 +701,9 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', '-I', 'test-app.js']) const expectedOutput = [ - 'STATUS 200', - 'content-type: text/plain; charset=UTF-8', - 'x-custom-header: PrioritizeValue', + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: PrioritizeValue', '', ].join('\n') @@ -728,8 +728,8 @@ describe('requestCommand', () => { ]) const expectedOutput = [ - 'STATUS 200', - 'content-type: application/json', + '200', + '\x1b[1mcontent-type\x1b[0m: application/json', '', JSON.stringify(jsonBody, null, 2), ].join('\n') diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 8913329..2cd7f9d 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -93,9 +93,9 @@ function getOutputData( } const headerLines: string[] = [] - headerLines.push(`STATUS ${status}`) + headerLines.push(`${status}`) for (const key in headers) { - headerLines.push(`${key}: ${headers[key]}`) + headerLines.push(`\x1b[1m${key}\x1b[0m: ${headers[key]}`) } const headerOutput = headerLines.join('\n') if (options.head) { From 52a8745864dc33fa2291be2ebe69fc8ef8bcf5e6 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:45:33 +0900 Subject: [PATCH 16/21] feat(request): `-J` and `--json` option fix `formatResponseBody()` return type. string to string | object Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 23 +++++++++++++++++++++++ src/commands/request/index.ts | 14 +++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 39f2593..ba00953 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -188,6 +188,29 @@ describe('requestCommand', () => { ) }) + // This test validates that `formatResponseBody` returns an object (not a string) when the response is JSON and the -J flag is used. + // It ensures that the final output JSON contains the response body as a nested object, rather than a double-stringified JSON string. + it('should return object body in JSON output when response is JSON and -J is used', async () => { + const mockApp = new Hono() + const jsonBody = { foo: 'bar', nested: { a: 1 } } + mockApp.get('/json-obj', (c) => c.json(jsonBody)) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/json-obj', '-J', 'test-app.js']) + + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify( + { + status: 200, + body: jsonBody, // Should be the object itself, not stringified JSON string + headers: { 'content-type': 'application/json' }, + }, + null, + 2 + ) + ) + }) + it('should handle POST request with data', async () => { const mockApp = new Hono() mockApp.post('/data', async (c) => { diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 2cd7f9d..1e14b38 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -82,12 +82,12 @@ export function requestCommand(program: Command) { function getOutputData( buffer: ArrayBuffer, - outputBody: string, + outputBody: string | object, isBinaryData: boolean, options: RequestOptions, status: number, headers: Record -): string | ArrayBuffer { +): string | ArrayBuffer | object { if (isBinaryData) { return buffer } @@ -112,7 +112,7 @@ function getOutputData( } async function handleSaveOutput( - saveData: string | ArrayBuffer, + saveData: string | ArrayBuffer | object, requestPath: string, options: RequestOptions ): Promise { @@ -124,7 +124,11 @@ async function handleSaveOutput( } try { await saveFile( - typeof saveData === 'string' ? new TextEncoder().encode(saveData).buffer : saveData, + typeof saveData === 'string' + ? new TextEncoder().encode(saveData).buffer + : saveData instanceof ArrayBuffer + ? saveData + : new TextEncoder().encode(JSON.stringify(saveData)).buffer, filepath ) console.log(`Saved response to ${filepath}`) @@ -217,7 +221,7 @@ const formatResponseBody = ( responseBody: string, contentType: string | undefined, jsonOption: boolean -): string => { +): string | object => { if (contentType && contentType.indexOf('application/json') !== -1) { try { const parsedJSON = JSON.parse(responseBody) From c5439f86a05beb4c8c7bc97cc781e59ddb55d190 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:53:35 +0900 Subject: [PATCH 17/21] feat(request): `-o ` and `-O` option To be able to save when request to / Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 23 +++++++++++++++++++++++ src/utils/file.test.ts | 4 ++++ src/utils/file.ts | 3 +++ 3 files changed, 30 insertions(+) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index ba00953..1332e33 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -605,6 +605,29 @@ describe('requestCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to image.png`) }) + it('should save response to "index" when remote-name option is used with root path', async () => { + const mockApp = new Hono() + const htmlContent = 'Home' + mockApp.get('/', (c) => c.html(htmlContent)) + setupBasicMocks('test-app.js', mockApp) + + const mockSaveFile = vi.mocked((await import('../../utils/file.js')).saveFile) + mockSaveFile.mockResolvedValue(undefined) + const mockGetFilenameFromPath = vi.mocked( + (await import('../../utils/file.js')).getFilenameFromPath + ) + mockGetFilenameFromPath.mockReturnValue('index') + + await program.parseAsync(['node', 'test', 'request', '-P', '/', '-O', 'test-app.js']) + + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/') + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(htmlContent).buffer, + 'index' + ) + expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index`) + }) + it('should prioritize -o over -O when both are present', async () => { const mockApp = new Hono() const textContent = 'Text content' diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 9dacf0b..abf557c 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -20,6 +20,10 @@ describe('getFilenameFromPath', () => { it('should extract filename from path with hostname', () => { expect(getFilenameFromPath('http://example.com:8080/foo/bar.txt')).toBe('bar.txt') }) + + it('should return "index" if the path is root', () => { + expect(getFilenameFromPath('/')).toBe('index') + }) }) describe('saveFile', () => { diff --git a/src/utils/file.ts b/src/utils/file.ts index 98da9d9..4c24f88 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -7,6 +7,9 @@ export const getFilenameFromPath = (path: string): string => { // If 'path' is an absolute URL, the base argument is ignored. const url = new URL(path, 'http://localhost') const pathname = url.pathname + if (pathname === '/') { + return 'index' + } const name = basename(pathname) return name From d40462da5ec5fc5443076ada655cb60e87d5b445 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Wed, 21 Jan 2026 17:59:37 +0900 Subject: [PATCH 18/21] chore: format Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 1332e33..d088fab 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -621,10 +621,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/') - expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(htmlContent).buffer, - 'index' - ) + expect(mockSaveFile).toHaveBeenCalledWith(new TextEncoder().encode(htmlContent).buffer, 'index') expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index`) }) From 71f80208615ebb7c0d12a3df2355806ef0fd3a68 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Thu, 22 Jan 2026 18:03:04 +0900 Subject: [PATCH 19/21] feat(request): `-J` and `--json` option fix indexOf to regular expressions Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 53 ++++++++++++++++++++++++++++++ src/commands/request/index.ts | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index d088fab..d1fcaee 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -779,4 +779,57 @@ describe('requestCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) }) + + describe('Content-Type JSON detection', () => { + const jsonString = '{"foo":"bar"}' + const formattedJsonString = JSON.stringify(JSON.parse(jsonString), null, 2) + + const matchingTypes = [ + 'application/json', + 'APPLICATION/JSON', + 'application/json; charset=utf-8', + 'application/json; charset=UTF-8; boundary=something', + 'application/ld+json', + 'application/hal+json', + 'application/vnd.api+json', + 'application/merge-patch+json', + 'application/problem+json', + 'application/geo+json', + ] + + const nonMatchingTypes = [ + 'application/jsonx', + 'application/jsonapi', + 'application/json+ld', + 'application/json+hal', + 'text/json', + 'text/plain', + 'application/xml', + 'text/plain; application/json', + ] + + matchingTypes.forEach((contentType) => { + it(`should format JSON for Content-Type: ${contentType}`, async () => { + const mockApp = new Hono() + mockApp.get('/test', (c) => c.body(jsonString, 200, { 'Content-Type': contentType })) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/test', 'test-app.js']) + + expect(consoleLogSpy).toHaveBeenCalledWith(formattedJsonString) + }) + }) + + nonMatchingTypes.forEach((contentType) => { + it(`should NOT format JSON for Content-Type: ${contentType}`, async () => { + const mockApp = new Hono() + mockApp.get('/test', (c) => c.body(jsonString, 200, { 'Content-Type': contentType })) + setupBasicMocks('test-app.js', mockApp) + + await program.parseAsync(['node', 'test', 'request', '-P', '/test', 'test-app.js']) + + expect(consoleLogSpy).toHaveBeenCalledWith(jsonString) + }) + }) + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 1e14b38..9fe1b7a 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -222,7 +222,7 @@ const formatResponseBody = ( contentType: string | undefined, jsonOption: boolean ): string | object => { - if (contentType && contentType.indexOf('application/json') !== -1) { + if (contentType && /^application\/(json|[^;\s]+\+json)($|;)/i.test(contentType)) { try { const parsedJSON = JSON.parse(responseBody) if (jsonOption) { From 6be5b280ca4af2f030acc79980d2369768809b7e Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Fri, 23 Jan 2026 17:29:41 +0900 Subject: [PATCH 20/21] feat(request): `-o ` and `-O` option For some contentTypes, set the extension Signed-off-by: ysknsid25 --- src/commands/request/index.test.ts | 6 +++--- src/commands/request/index.ts | 10 ++++++---- src/utils/file.test.ts | 11 +++++++++++ src/utils/file.ts | 25 ++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index d1fcaee..79e0c40 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -577,7 +577,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/index.html', '-O', 'test-app.js']) - expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html') + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html', 'text/html; charset=UTF-8') expect(mockSaveFile).toHaveBeenCalledWith( new TextEncoder().encode(htmlContent).buffer, 'index.html' @@ -600,7 +600,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', '-O', 'test-app.js']) - expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png') + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png', 'image/png') expect(mockSaveFile).toHaveBeenCalledWith(pngData, 'image.png') expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to image.png`) }) @@ -620,7 +620,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/', '-O', 'test-app.js']) - expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/') + expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/', 'text/html; charset=UTF-8') expect(mockSaveFile).toHaveBeenCalledWith(new TextEncoder().encode(htmlContent).buffer, 'index') expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index`) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 9fe1b7a..ec5d0ad 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -49,9 +49,10 @@ export function requestCommand(program: Command) { const buildIterator = getBuildIterator(file, watch) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) + const contentType = result.headers['content-type'] const outputBody = formatResponseBody( result.body, - result.headers['content-type'], + contentType, options.json && !options.include ) const buffer = await result.response.clone().arrayBuffer() @@ -74,7 +75,7 @@ export function requestCommand(program: Command) { } if (doSaveFile) { - await handleSaveOutput(outputData, path, options) + await handleSaveOutput(outputData, path, options, contentType) } } }) @@ -114,13 +115,14 @@ function getOutputData( async function handleSaveOutput( saveData: string | ArrayBuffer | object, requestPath: string, - options: RequestOptions + options: RequestOptions, + contentType?: string ): Promise { let filepath: string if (options.output) { filepath = options.output } else { - filepath = getFilenameFromPath(requestPath) + filepath = getFilenameFromPath(requestPath, contentType) } try { await saveFile( diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index abf557c..46aa94a 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -24,6 +24,17 @@ describe('getFilenameFromPath', () => { it('should return "index" if the path is root', () => { expect(getFilenameFromPath('/')).toBe('index') }) + + it.each([ + ['application/json', 'index.json'], + ['text/html', 'index.html'], + ['text/plain', 'index.txt'], + ['application/xml', 'index.xml'], + ['text/html; charset=utf-8', 'index.html'], + ['unknown/type', 'index'], + ])('given content-type "%s", should return "%s"', (contentType, expected) => { + expect(getFilenameFromPath('/', contentType)).toBe(expected) + }) }) describe('saveFile', () => { diff --git a/src/utils/file.ts b/src/utils/file.ts index 4c24f88..6aac19e 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,13 +1,17 @@ import { existsSync, createWriteStream } from 'node:fs' import { dirname, basename } from 'node:path' -export const getFilenameFromPath = (path: string): string => { +export const getFilenameFromPath = (path: string, contentType?: string): string => { // We use 'http://localhost' as a base because 'path' is often a relative path (e.g., '/users/123'). // The URL constructor requires a base URL for relative paths to parse them correctly. // If 'path' is an absolute URL, the base argument is ignored. const url = new URL(path, 'http://localhost') const pathname = url.pathname if (pathname === '/') { + const extension = getMimeTypeToExtension(contentType) + if (extension) { + return `index.${extension}` + } return 'index' } const name = basename(pathname) @@ -61,3 +65,22 @@ export const saveFile = async (buffer: ArrayBuffer, filepath: string): Promise { + if (!contentType) { + return '' + } + if (/^application\/(json|[^;\s]+\+json)($|;)/i.test(contentType)) { + return 'json' + } + if (/^text\/html($|;)/i.test(contentType)) { + return 'html' + } + if (/^text\/plain($|;)/i.test(contentType)) { + return 'txt' + } + if (/^(application|text)\/xml($|;)/i.test(contentType)) { + return 'xml' + } + return '' +} From 253b360bd8bb1323149d0cbe3482f6c5d0d25b40 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Sat, 31 Jan 2026 13:40:15 +0900 Subject: [PATCH 21/21] feat(request): `-o ` and `-O` option For some contentTypes, set the extension by using `hono/utils/mime` Signed-off-by: ysknsid25 --- src/utils/file.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++-- src/utils/file.ts | 32 +++++++++----------------- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 46aa94a..94b0387 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -27,10 +27,57 @@ describe('getFilenameFromPath', () => { it.each([ ['application/json', 'index.json'], - ['text/html', 'index.html'], + ['text/html', 'index.htm'], ['text/plain', 'index.txt'], ['application/xml', 'index.xml'], - ['text/html; charset=utf-8', 'index.html'], + ['text/html; charset=utf-8', 'index.htm'], + ['charset=UTF-8; application/json; boundary=something', 'index.json'], + ['image/png', 'index.png'], + ['image/jpeg', 'index.jpeg'], + ['image/webp', 'index.webp'], + ['audio/mpeg', 'index.mp3'], + ['video/mp4', 'index.mp4'], + ['application/pdf', 'index.pdf'], + ['application/zip', 'index.zip'], + ['text/css', 'index.css'], + ['text/javascript', 'index.js'], + ['application/wasm', 'index.wasm'], + ['foo=bar; image/avif', 'index.avif'], + ['audio/aac', 'index.aac'], + ['video/x-msvideo', 'index.avi'], + ['video/av1', 'index.av1'], + ['application/octet-stream', 'index.bin'], + ['image/bmp', 'index.bmp'], + ['text/csv', 'index.csv'], + ['application/vnd.ms-fontobject', 'index.eot'], + ['application/epub+zip', 'index.epub'], + ['image/gif', 'index.gif'], + ['application/gzip', 'index.gz'], + ['image/x-icon', 'index.ico'], + ['text/calendar', 'index.ics'], + ['application/ld+json', 'index.jsonld'], + ['audio/x-midi', 'index.mid'], + ['video/mpeg', 'index.mpeg'], + ['audio/ogg', 'index.oga'], + ['video/ogg', 'index.ogv'], + ['application/ogg', 'index.ogx'], + ['audio/opus', 'index.opus'], + ['font/otf', 'index.otf'], + ['application/rtf', 'index.rtf'], + ['image/svg+xml', 'index.svg'], + ['image/tiff', 'index.tif'], + ['video/mp2t', 'index.ts'], + ['font/ttf', 'index.ttf'], + ['video/webm', 'index.webm'], + ['audio/webm', 'index.weba'], + ['application/manifest+json', 'index.webmanifest'], + ['font/woff', 'index.woff'], + ['font/woff2', 'index.woff2'], + ['application/xhtml+xml', 'index.xhtml'], + ['video/3gpp', 'index.3gp'], + ['video/3gpp2', 'index.3g2'], + ['model/gltf+json', 'index.gltf'], + ['model/gltf-binary', 'index.glb'], ['unknown/type', 'index'], ])('given content-type "%s", should return "%s"', (contentType, expected) => { expect(getFilenameFromPath('/', contentType)).toBe(expected) diff --git a/src/utils/file.ts b/src/utils/file.ts index 6aac19e..c7ac298 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,3 +1,4 @@ +import { getExtension } from 'hono/utils/mime' import { existsSync, createWriteStream } from 'node:fs' import { dirname, basename } from 'node:path' @@ -8,9 +9,15 @@ export const getFilenameFromPath = (path: string, contentType?: string): string const url = new URL(path, 'http://localhost') const pathname = url.pathname if (pathname === '/') { - const extension = getMimeTypeToExtension(contentType) - if (extension) { - return `index.${extension}` + if (contentType) { + const parts = contentType.split(';') + for (const part of parts) { + const mimeType = part.trim().toLowerCase() + const extension = getExtension(mimeType) + if (extension) { + return `index.${extension}` + } + } } return 'index' } @@ -65,22 +72,3 @@ export const saveFile = async (buffer: ArrayBuffer, filepath: string): Promise { - if (!contentType) { - return '' - } - if (/^application\/(json|[^;\s]+\+json)($|;)/i.test(contentType)) { - return 'json' - } - if (/^text\/html($|;)/i.test(contentType)) { - return 'html' - } - if (/^text\/plain($|;)/i.test(contentType)) { - return 'txt' - } - if (/^(application|text)\/xml($|;)/i.test(contentType)) { - return 'xml' - } - return '' -}