diff --git a/README.md b/README.md index 65a8f00..4ebbcce 100644 --- a/README.md +++ b/README.md @@ -157,6 +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 +- `-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 - `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 17f574b..c7a9e0f 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -18,9 +18,15 @@ 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 + let consoleWarnSpy: ReturnType let mockModules: any let mockBuildAndImportApp: any @@ -51,6 +57,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,9 +73,28 @@ describe('requestCommand', () => { afterEach(() => { consoleLogSpy.mockRestore() + consoleWarnSpy.mockRestore() 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' })) @@ -76,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') @@ -91,7 +117,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: { message: 'Hello' }, headers: { 'content-type': 'application/json' }, }, null, @@ -100,14 +126,14 @@ 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' })) 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') @@ -122,7 +148,63 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: { message: 'Hello' }, + headers: { 'content-type': 'application/json' }, + }, + null, + 2 + ) + ) + }) + + 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 + ) + ) + }) + + // 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, @@ -152,6 +234,7 @@ describe('requestCommand', () => { '-d', 'test data', 'test-app.js', + '-J', ]) // Verify resolve was called with correct arguments @@ -160,7 +243,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 201, - body: '{"received":"test data"}', + body: { received: 'test data' }, headers: { 'content-type': 'application/json', 'x-custom-header': 'test-value' }, }, null, @@ -187,7 +270,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') @@ -197,7 +280,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 200, - body: '{"message":"Default app"}', + body: { message: 'Default app' }, headers: { 'content-type': 'application/json' }, }, null, @@ -229,13 +312,16 @@ describe('requestCommand', () => { '-H', 'Authorization: Bearer token123', 'test-app.js', + '-J', ]) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( { status: 200, - body: '{"auth":"Bearer token123"}', + body: { + auth: 'Bearer token123', + }, headers: { 'content-type': 'application/json' }, }, null, @@ -269,13 +355,14 @@ describe('requestCommand', () => { '-H', 'X-Custom-Header: custom-value', 'test-app.js', + '-J', ]) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( { status: 200, - body: '{"auth":"Bearer token456","userAgent":"TestClient/1.0","custom":"custom-value"}', + body: { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, headers: { 'content-type': 'application/json' }, }, null, @@ -294,7 +381,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 @@ -323,6 +418,7 @@ describe('requestCommand', () => { '-H', 'ValidHeader: value', 'test-app.js', + '-J', ]) // Should still work, malformed header is ignored @@ -330,7 +426,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"success":true}', + body: { success: true }, headers: { 'content-type': 'application/json' }, }, null, @@ -339,6 +435,353 @@ 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(htmlContent) + }) + + 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(xmlContent) + }) + + 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 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' } + 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(jsonBody)).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', 'text/html; charset=UTF-8') + expect(mockSaveFile).toHaveBeenCalledWith( + new TextEncoder().encode(htmlContent).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', 'image/png') + expect(mockSaveFile).toHaveBeenCalledWith(pngData, 'image.png') + 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('/', 'text/html; charset=UTF-8') + 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' + 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', + '-J', + ]) + + 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 protocol headers and save when default', 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', + '-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}`) + }) + + 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 = [ + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: 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 = [ + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: 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 = [ + '200', + '\x1b[1mcontent-type\x1b[0m: text/plain; charset=UTF-8', + '\x1b[1mx-custom-header\x1b[0m: PrioritizeValue', + '', + ].join('\n') + + 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 = [ + '200', + '\x1b[1mcontent-type\x1b[0m: application/json', + '', + JSON.stringify(jsonBody, null, 2), + ].join('\n') + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) + it('should handle single external option', async () => { const mockApp = new Hono() mockApp.get('/', (c) => c.json({ message: 'Hello' })) @@ -406,4 +849,57 @@ describe('requestCommand', () => { sourcemap: true, }) }) + + 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 9f28769..958ab8d 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'] @@ -12,6 +13,11 @@ interface RequestOptions { header?: string[] path?: string watch: boolean + json: boolean + output?: string + remoteName: boolean + include: boolean + head: boolean external?: string[] } @@ -24,6 +30,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('-J, --json', 'Output response as JSON', false) .option( '-H, --header
', 'Custom headers', @@ -32,6 +39,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) + .option('-i, --include', 'Include protocol and headers in the output', false) + .option('-I, --head', 'Show only protocol and headers in the output', false) .option( '-e, --external ', 'Mark package as external (can be used multiple times)', @@ -41,17 +52,104 @@ export function requestCommand(program: Command) { [] as string[] ) .action(async (file: string | undefined, options: RequestOptions) => { + const doSaveFile = options.output || options.remoteName const path = options.path || '/' const watch = options.watch const external = options.external || [] const buildIterator = getBuildIterator(file, watch, external) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) - console.log(JSON.stringify(result, null, 2)) + const contentType = result.headers['content-type'] + const outputBody = formatResponseBody( + result.body, + contentType, + options.json && !options.include + ) + const buffer = await result.response.clone().arrayBuffer() + const isBinaryData = isBinaryResponse(buffer) + if (isBinaryData && !doSaveFile) { + console.warn('Binary output can mess up your terminal.') + continue + } + + const outputData = getOutputData( + buffer, + outputBody, + isBinaryData, + options, + result.status, + result.headers + ) + if (!isBinaryData) { + console.log(outputData) + } + + if (doSaveFile) { + await handleSaveOutput(outputData, path, options, contentType) + } } }) } +function getOutputData( + buffer: ArrayBuffer, + outputBody: string | object, + isBinaryData: boolean, + options: RequestOptions, + status: number, + headers: Record +): string | ArrayBuffer | object { + if (isBinaryData) { + return buffer + } + + const headerLines: string[] = [] + headerLines.push(`${status}`) + for (const key in headers) { + headerLines.push(`\x1b[1m${key}\x1b[0m: ${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( + saveData: string | ArrayBuffer | object, + requestPath: string, + options: RequestOptions, + contentType?: string +): Promise { + let filepath: string + if (options.output) { + filepath = options.output + } else { + filepath = getFilenameFromPath(requestPath, contentType) + } + try { + await saveFile( + 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}`) + // 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, @@ -89,7 +187,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 = { @@ -123,11 +221,43 @@ 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 formatResponseBody = ( + responseBody: string, + contentType: string | undefined, + jsonOption: boolean +): string | object => { + if (contentType && /^application\/(json|[^;\s]+\+json)($|;)/i.test(contentType)) { + 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 + } + } + 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/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 0000000..94b0387 --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,134 @@ +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') + }) + + it('should return "index" if the path is root', () => { + expect(getFilenameFromPath('/')).toBe('index') + }) + + it.each([ + ['application/json', 'index.json'], + ['text/html', 'index.htm'], + ['text/plain', 'index.txt'], + ['application/xml', 'index.xml'], + ['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) + }) +}) + +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..c7ac298 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,74 @@ +import { getExtension } from 'hono/utils/mime' +import { existsSync, createWriteStream } from 'node:fs' +import { dirname, basename } from 'node:path' + +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 === '/') { + 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' + } + 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) + }) +}