diff --git a/README.md b/README.md index fc87617..c67cf7f 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 **Examples:** diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 3790cfd..04f5300 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') @@ -90,7 +116,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: { message: 'Hello' }, headers: { 'content-type': 'application/json' }, }, null, @@ -99,14 +125,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') @@ -120,7 +146,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"message":"Hello"}', + body: { message: 'Hello' }, headers: { 'content-type': 'application/json' }, }, null, @@ -150,6 +176,7 @@ describe('requestCommand', () => { '-d', 'test data', 'test-app.js', + '-J', ]) // Verify resolve was called with correct arguments @@ -158,7 +185,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, @@ -185,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') @@ -195,7 +222,7 @@ describe('requestCommand', () => { const expectedOutput = JSON.stringify( { status: 200, - body: '{"message":"Default app"}', + body: { message: 'Default app' }, headers: { 'content-type': 'application/json' }, }, null, @@ -227,13 +254,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, @@ -267,13 +297,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, @@ -292,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 @@ -321,6 +360,7 @@ describe('requestCommand', () => { '-H', 'ValidHeader: value', 'test-app.js', + '-J', ]) // Should still work, malformed header is ignored @@ -328,7 +368,7 @@ describe('requestCommand', () => { JSON.stringify( { status: 200, - body: '{"success":true}', + body: { success: true }, headers: { 'content-type': 'application/json' }, }, null, @@ -336,4 +376,297 @@ 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 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') + 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') + 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', + '-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 = [ + '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) + }) + + 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 cc84a54..8ef32aa 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 } export function requestCommand(program: Command) { @@ -23,6 +29,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', @@ -31,17 +38,102 @@ 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) .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) for await (const app of buildIterator) { const result = await executeRequest(app, path, options) - console.log(JSON.stringify(result, null, 2)) + const outputBody = formatResponseBody( + result.body, + result.headers['content-type'], + 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.') + return + } + + 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 + } + + 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( + 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 @@ -77,7 +169,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 +203,45 @@ 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 => { + 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 + } + default: + 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..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) + }) +}