diff --git a/packages/insomnia-data/src/models/response.ts b/packages/insomnia-data/src/models/response.ts index 87c7614de3..cc356dce16 100644 --- a/packages/insomnia-data/src/models/response.ts +++ b/packages/insomnia-data/src/models/response.ts @@ -34,7 +34,7 @@ export interface BaseResponse { headers: ResponseHeader[]; bodyPath: string; // if body is less than 5MB, it's stored in memory - bodyBuffer?: Buffer; + bodyBuffer?: Uint8Array; // Actual bodies are stored on the filesystem timelinePath: string; // Actual timelines are stored on the filesystem diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index e533f32adf..2daa99915c 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -204,8 +204,8 @@ export interface RendererToMainBridgeAPI { installPlugin: typeof installPlugin; initializeWorkspaceBackendProject: typeof initializeWorkspaceBackendProject; parseImport: typeof convert; - multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise; - writeFile: (options: { path: string; content: string | Buffer }) => Promise; + multipartBufferToArray: (options: { bodyBuffer: Uint8Array | null; contentType: string }) => Promise; + writeFile: (options: { path: string; content: string | Uint8Array }) => Promise; deleteCompiledRuleset: (options: { projectId: string }) => Promise; refreshCompiledRuleset: (options: { projectId: string; rulesetContent: string }) => Promise<{ compiledPath: string }>; writeResponseBodyToFile: (options: { @@ -377,12 +377,7 @@ export function registerMainHandlers() { throw new TypeError(`Unknown service method: ${serviceName}.${methodName}`); } const result = await (fn as (...args: unknown[]) => unknown).call(service, ...args); - // Tag Buffer results before contextBridge serializes them as plain Uint8Array, - // so the preload can distinguish them from intentional Uint8Array returns. - if (Buffer.isBuffer(result)) { - return { __type: 'Buffer', data: Array.from(result as Buffer) }; - } - return result; + return Buffer.isBuffer(result) ? new Uint8Array(result) : result; }); ipcMainHandle('multipartBufferToArray', async (_, options) => { return multipartBufferToArray(options); @@ -426,7 +421,7 @@ export function registerMainHandlers() { return initializeWorkspaceBackendProject(options); }, ); - ipcMainHandle('writeFile', async (_, options: { path: string; content: string | Buffer }) => { + ipcMainHandle('writeFile', async (_, options: { path: string; content: string | Uint8Array }) => { try { const dir = path.dirname(options.path); await fs.promises.mkdir(dir, { recursive: true }); diff --git a/packages/insomnia/src/main/multipart-buffer-to-array.ts b/packages/insomnia/src/main/multipart-buffer-to-array.ts index e1bcdef99a..5e4c3cee05 100644 --- a/packages/insomnia/src/main/multipart-buffer-to-array.ts +++ b/packages/insomnia/src/main/multipart-buffer-to-array.ts @@ -7,7 +7,7 @@ export interface Part { title: string; name: string; bytes: number; - value: Buffer; + value: Uint8Array; filename: string | null; headers: { name: string; value: string }[]; } @@ -15,7 +15,7 @@ export function multipartBufferToArray({ bodyBuffer, contentType, }: { - bodyBuffer: Buffer | null; + bodyBuffer: Uint8Array | null; contentType: string; }): Promise { return new Promise((resolve, reject) => { diff --git a/packages/insomnia/src/network/basic-auth/get-header.ts b/packages/insomnia/src/network/basic-auth/get-header.ts index 7937af85ff..ea6000d1a1 100644 --- a/packages/insomnia/src/network/basic-auth/get-header.ts +++ b/packages/insomnia/src/network/basic-auth/get-header.ts @@ -1,10 +1,11 @@ import type { RequestHeader } from 'insomnia-data'; +import { bytesToBase64, latin1BytesFromString, utf8ToBase64 } from '~/utils/utf8-bytes'; + export function getBasicAuthHeader(username?: string | null, password?: string | null, encoding = 'utf8') { const name = 'Authorization'; const header = `${username || ''}:${password || ''}`; - // @ts-expect-error -- TSCONVERSION appears to be a genuine error - const authString = Buffer.from(header, encoding).toString('base64'); + const authString = encoding === 'latin1' ? bytesToBase64(latin1BytesFromString(header)) : utf8ToBase64(header); const value = `Basic ${authString}`; const requestHeader: RequestHeader = { name, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx index 2e58e8e086..8582ef05af 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx @@ -173,7 +173,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { // Oversized repsonses are handled in the response-viewer.tsx for now if (!isOversizedResponse) { const buffer = await services.helpers.getResponseBodyBuffer(activeResponse); - activeResponse.bodyBuffer = typeof buffer === 'string' ? Buffer.from(buffer) : buffer; + activeResponse.bodyBuffer = typeof buffer === 'string' ? undefined : buffer; } } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx index e05d4094a0..ad0b009775 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx @@ -33,6 +33,7 @@ import { EmptyStatePane } from '~/ui/components/panes/empty-state-pane'; import { Pane, PaneBody, PaneHeader } from '~/ui/components/panes/pane'; import { SvgIcon } from '~/ui/components/svg-icon'; import { invariant } from '~/utils/invariant'; +import { utf8ByteLength } from '~/utils/utf8-bytes'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId'; @@ -65,7 +66,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { // Oversized responses are handled in the response-viewer.tsx for now if (!isOversizedResponse) { const buffer = await services.helpers.getResponseBodyBuffer(activeResponse); - activeResponse.bodyBuffer = typeof buffer === 'string' ? Buffer.from(buffer) : buffer; + activeResponse.bodyBuffer = typeof buffer === 'string' ? undefined : buffer; } } return { @@ -106,7 +107,7 @@ export const mockRouteToHar = async ({ headers: validHeaders, cookies: await window.main.cookies.getResponseCookiesFromHeaders(validHeaders), content: { - size: Buffer.byteLength(body), + size: utf8ByteLength(body), mimeType, text: body, compression: 0, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 221768d857..d8d87e9d91 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -62,6 +62,7 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features'; import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; +import { utf8ByteLength } from '~/utils/utf8-bytes'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; @@ -435,8 +436,7 @@ const Component = ({ params }: Route.ComponentProps) => { } const RULESET_MAX_BYTES = 1 * 1024 * 1024; // 1 MB - const byteLength = new TextEncoder().encode(content).byteLength; - if (byteLength > RULESET_MAX_BYTES) { + if (utf8ByteLength(content) > RULESET_MAX_BYTES) { showError({ title: 'Ruleset Too Large', message: 'The selected ruleset exceeds the maximum allowed size of 1 MB.', diff --git a/packages/insomnia/src/templating/local-template-tags.ts b/packages/insomnia/src/templating/local-template-tags.ts index 5e82b5b773..f49b7b57e1 100644 --- a/packages/insomnia/src/templating/local-template-tags.ts +++ b/packages/insomnia/src/templating/local-template-tags.ts @@ -2,6 +2,7 @@ import { format } from 'date-fns'; import type { TemplateTag } from 'insomnia/src/plugins/types'; import type { PluginTemplateTag } from 'insomnia/src/templating/types'; import { invariant } from 'insomnia/src/utils/invariant'; +import { utf8StringFromBytes } from 'insomnia/src/utils/utf8-bytes'; import JSONBig from 'json-bigint'; import { JSONPath } from 'jsonpath-plus'; @@ -673,7 +674,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ return context.util.decode(bodyBuffer, charset); } catch (err) { console.warn('[response] Failed to decode body', err); - return bodyBuffer.toString(); + return utf8StringFromBytes(bodyBuffer); } } if (field === 'header') { @@ -698,7 +699,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ body = await context.util.decode(bodyBuffer, charset); } catch (err) { console.warn('[response] Failed to decode body', err); - body = bodyBuffer.toString(); + body = utf8StringFromBytes(bodyBuffer); } if (sanitizedFilter.indexOf('$') === 0) { diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index 4b6cb562ba..49a95a1052 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -2,6 +2,7 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; import { models, services } from 'insomnia-data'; import { decryptSecretValue } from '~/utils/crypt-adapter'; +import { base64ToUtf8, utf8ToBase64 } from '~/utils/utf8-bytes'; import type { NunjucksParsedTag, NunjucksParsedTagArg, RenderPurpose } from '../templating/types'; import { decryptVaultKeyFromSession } from '../utils/vault'; @@ -122,7 +123,7 @@ export function encodeEncoding(value: T, encoding?: 'base64') { } if (encoding === 'base64') { - const encodedValue = Buffer.from(value, 'utf8').toString('base64'); + const encodedValue = utf8ToBase64(value); return `b64::${encodedValue}::46b`; } @@ -137,11 +138,7 @@ export function decodeEncoding(value: T) { const results = value.match(/^b64::(.+)::46b$/); if (results) { - if (typeof Buffer !== 'undefined') { - return Buffer.from(results[1], 'base64').toString('utf8'); - } - // Fallback for browser environments - return atob(results[1]); + return base64ToUtf8(results[1]); } return value; diff --git a/packages/insomnia/src/ui/components/base/prompt-button.tsx b/packages/insomnia/src/ui/components/base/prompt-button.tsx index 583630c900..1643844bfc 100644 --- a/packages/insomnia/src/ui/components/base/prompt-button.tsx +++ b/packages/insomnia/src/ui/components/base/prompt-button.tsx @@ -41,9 +41,8 @@ export const PromptButton = ({ // Create flag to store the state value. const [state, setState] = useState('default'); - // Timeout instancies - const doneTimeout = useRef(null); - const triggerTimeout = useRef(null); + const doneTimeout = useRef(null); + const triggerTimeout = useRef(null); useEffect(() => { return () => { @@ -59,9 +58,7 @@ export const PromptButton = ({ event.stopPropagation(); // Toggle the confirmation notice setState('ask'); - // Set a timeout to hide the confirmation - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - triggerTimeout.current = global.setTimeout(() => { + triggerTimeout.current = window.setTimeout(() => { setState('default'); }, 2000); } @@ -73,14 +70,10 @@ export const PromptButton = ({ // Fire the click handler const retVal: any = onClick?.(event); if (!referToOnClickReturnValue) { - // Set the state to done (but delay a bit to not alarm user) - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - doneTimeout.current = global.setTimeout(() => { + doneTimeout.current = window.setTimeout(() => { setState('done'); }, 100); - // Set a timeout to hide the confirmation - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - triggerTimeout.current = global.setTimeout(() => { + triggerTimeout.current = window.setTimeout(() => { setState('default'); }, 2000); } else { @@ -91,7 +84,7 @@ export const PromptButton = ({ setState('done'); }) .finally(() => { - triggerTimeout.current = global.setTimeout(() => { + triggerTimeout.current = window.setTimeout(() => { setState('default'); }, 1000); }); diff --git a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx index 5d547c84b5..191397f21a 100644 --- a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx @@ -3,6 +3,8 @@ import { getPreviewModeName, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from 'insomnia import React, { type FC, useCallback } from 'react'; import { Button } from 'react-aria-components'; +import { bodyBufferToUtf8 } from '~/utils/utf8-bytes'; + import { type RequestLoaderData, useRequestLoaderData, @@ -81,7 +83,7 @@ export const PreviewModeDropdown: FC = ({ download, copyToClipboard }) => if (filePath && activeResponse.bodyBuffer) { await window.main.writeFile({ path: filePath, - content: headers + '\n' + activeResponse.bodyBuffer.toString('utf8') || '', + content: headers + '\n' + bodyBufferToUtf8(activeResponse.bodyBuffer) || '', }); } }, [activeRequest, activeResponse]); diff --git a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx index ec5ff7e26e..84e5fa3596 100644 --- a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx @@ -25,6 +25,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import * as reactUse from 'react-use'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; +import { bodyBufferToUtf8 } from '~/utils/utf8-bytes'; import { CONTENT_TYPE_JSON } from '../../../../common/constants'; import { database as db } from '../../../../common/database'; @@ -186,7 +187,7 @@ const fetchGraphQLSchemaForRequest = async ({ } const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); if (bodyBuffer) { - const { data, errors } = JSON.parse(bodyBuffer.toString()); + const { data, errors } = JSON.parse(bodyBufferToUtf8(bodyBuffer)); if (errors?.length) { return { schemaFetchError: errors[0] }; } diff --git a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx index dff5335232..9e1e656951 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx @@ -14,6 +14,7 @@ import { } from 'react-aria-components'; import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor'; +import { utf8ByteLength } from '~/utils/utf8-bytes'; import { describeByteSize, generateId } from '../../../common/misc'; import { FileInputButton } from '../base/file-input-button'; @@ -131,7 +132,7 @@ export const KeyValueEditor: FC = ({ const isFile = 'type' in pair && pair.type === 'file'; const isMultiline = 'type' in pair && pair.type === 'text' && pair.multiline; - const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + const bytes = isMultiline ? utf8ByteLength(pair.value) : 0; let valueEditor = (
@@ -291,7 +292,7 @@ export const KeyValueEditor: FC = ({ {pair => { const isFile = pair.type === 'file'; const isMultiline = pair.type === 'text' && pair.multiline; - const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + const bytes = isMultiline ? utf8ByteLength(pair.value) : 0; const lowerName = pair.name.toLowerCase(); const isPairDisabled = !!readOnlyDisabledByName?.[lowerName]; @@ -394,7 +395,7 @@ export const KeyValueEditor: FC = ({ {pair => { const isFile = pair.type === 'file'; const isMultiline = pair.type === 'text' && pair.multiline; - const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + const bytes = isMultiline ? utf8ByteLength(pair.value) : 0; const isOnlyTextAllowed = !allowFile && !allowMultiline; let valueEditor = ( diff --git a/packages/insomnia/src/ui/components/key-value-editor/row.tsx b/packages/insomnia/src/ui/components/key-value-editor/row.tsx index 7d49ec11c0..2e503c3331 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/row.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/row.tsx @@ -3,6 +3,7 @@ import React, { type FC } from 'react'; import { Button } from 'react-aria-components'; import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor'; +import { utf8ByteLength } from '~/utils/utf8-bytes'; import { describeByteSize } from '../../../common/misc'; import { Dropdown, DropdownItem, ItemContent } from '../base/dropdown'; @@ -78,7 +79,7 @@ export const Row: FC = ({ const isFile = pair.type === 'file'; const isMultiline = pair.type === 'text' && pair.multiline; - const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + const bytes = isMultiline ? utf8ByteLength(pair.value) : 0; return (
  • diff --git a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx index 9c45211b07..29dc277218 100644 --- a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx +++ b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx @@ -10,6 +10,7 @@ import { useRootLoaderData } from '~/root'; import { useRequestNewMockSendActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new-mock-send'; import { useMockRouteLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId'; import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor'; +import { bodyBufferToUtf8 } from '~/utils/utf8-bytes'; import { getMockServiceURL } from '../../../common/constants'; import { cancelRequestById } from '../../../network/cancellation'; @@ -293,7 +294,7 @@ const PreviewModeDropdown = ({ label="Copy raw response" onClick={async () => { const bodyBuffer = await services.helpers.getResponseBodyBuffer(activeResponse); - bodyBuffer && window.clipboard.writeText(bodyBuffer.toString('utf8')); + bodyBuffer && window.clipboard.writeText(bodyBufferToUtf8(bodyBuffer)); }} /> @@ -313,7 +314,7 @@ const PreviewModeDropdown = ({ } await window.main.writeFile({ path: filePath, - content: activeResponse.bodyBuffer?.toString('utf8') || '', + content: bodyBufferToUtf8(activeResponse.bodyBuffer) || '', }); }} /> @@ -336,7 +337,7 @@ const PreviewModeDropdown = ({ } await window.main.writeFile({ path: filePath, - content: jsonPrettify(activeResponse.bodyBuffer?.toString('utf8')) || '', + content: jsonPrettify(bodyBufferToUtf8(bodyBuffer)) || '', }); }} /> diff --git a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts index a1891df1cf..7669969d34 100644 --- a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts +++ b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts @@ -34,7 +34,7 @@ describe('downloadResponseBody', () => { await downloadResponseBody( null, - { contentType: 'application/json', bodyBuffer: Buffer.from('{}') }, + { contentType: 'application/json', bodyBuffer: new TextEncoder().encode('{}') }, false, ); @@ -50,7 +50,7 @@ describe('downloadResponseBody', () => { await downloadResponseBody( { name: 'My Request' }, - { contentType: 'application/json', bodyBuffer: Buffer.from('{}') }, + { contentType: 'application/json', bodyBuffer: new TextEncoder().encode('{}') }, false, ); @@ -60,20 +60,20 @@ describe('downloadResponseBody', () => { }); describe('prettify branch (prettify=true, JSON content-type)', () => { - it('writes a prettified JSON string, not a Buffer', async () => { + it('writes a prettified JSON string, not raw bytes', async () => { mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' }); const rawJson = '{"b":2,"a":1}'; await downloadResponseBody( { name: 'My Request' }, - { contentType: 'application/json', bodyBuffer: Buffer.from(rawJson) }, + { contentType: 'application/json', bodyBuffer: new TextEncoder().encode(rawJson) }, true, ); expect(mockWriteFile).toHaveBeenCalledOnce(); const { path, content } = mockWriteFile.mock.calls[0][0]; expect(path).toBe('/tmp/out.json'); - // content must be a formatted string, not a Buffer + // content must be a formatted string, not raw bytes expect(typeof content).toBe('string'); expect(content).toContain('"b": 2'); expect(content).toContain('"a": 1'); @@ -81,10 +81,10 @@ describe('downloadResponseBody', () => { }); describe('raw-bytes branch (default)', () => { - it('writes the raw Buffer when prettify is false, preserving binary content', async () => { + it('writes raw bytes when prettify is false, preserving binary content', async () => { mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.png' }); // PNG magic bytes — would be corrupted by a UTF-8 round-trip - const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); await downloadResponseBody( { name: 'My Request' }, @@ -95,13 +95,13 @@ describe('downloadResponseBody', () => { expect(mockWriteFile).toHaveBeenCalledOnce(); const { path, content } = mockWriteFile.mock.calls[0][0]; expect(path).toBe('/tmp/out.png'); - expect(Buffer.isBuffer(content)).toBe(true); + expect(content).toBeInstanceOf(Uint8Array); expect(content).toEqual(binaryData); }); - it('writes the raw Buffer when prettify is true but the content-type is not JSON', async () => { + it('writes raw bytes when prettify is true but the content-type is not JSON', async () => { mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.txt' }); - const textData = Buffer.from('Hello, World!'); + const textData = new TextEncoder().encode('Hello, World!'); await downloadResponseBody( { name: 'My Request' }, @@ -111,11 +111,11 @@ describe('downloadResponseBody', () => { expect(mockWriteFile).toHaveBeenCalledOnce(); const { content } = mockWriteFile.mock.calls[0][0]; - expect(Buffer.isBuffer(content)).toBe(true); + expect(content).toBeInstanceOf(Uint8Array); expect(content).toEqual(textData); }); - it('writes an empty Buffer when bodyBuffer is null', async () => { + it('writes empty bytes when bodyBuffer is null', async () => { mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.bin' }); await downloadResponseBody( @@ -126,7 +126,7 @@ describe('downloadResponseBody', () => { expect(mockWriteFile).toHaveBeenCalledOnce(); const { content } = mockWriteFile.mock.calls[0][0]; - expect(Buffer.isBuffer(content)).toBe(true); + expect(content).toBeInstanceOf(Uint8Array); expect(content.length).toBe(0); }); }); diff --git a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts index d041cc3f5e..c10e9d14c2 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts +++ b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts @@ -1,10 +1,11 @@ import { extension as mimeExtension } from 'mime-types'; import { jsonPrettify } from '~/utils/prettify/json'; +import { bodyBufferToUtf8 } from '~/utils/utf8-bytes'; export async function downloadResponseBody( activeRequest: { name: string } | null | undefined, - activeResponse: { contentType: string; bodyBuffer?: Buffer | null } | null | undefined, + activeResponse: { contentType: string; bodyBuffer?: Uint8Array | null } | null | undefined, prettify: boolean, ) { if (!activeResponse || !activeRequest) { @@ -26,9 +27,9 @@ export async function downloadResponseBody( if (prettify && contentType.includes('json')) { await window.main.writeFile({ path: outputPath, - content: jsonPrettify(activeResponse.bodyBuffer?.toString('utf8')) || '', + content: jsonPrettify(bodyBufferToUtf8(activeResponse.bodyBuffer)) || '', }); return; } - await window.main.writeFile({ path: outputPath, content: activeResponse.bodyBuffer ?? Buffer.alloc(0) }); + await window.main.writeFile({ path: outputPath, content: activeResponse.bodyBuffer ?? new Uint8Array(0) }); } diff --git a/packages/insomnia/src/ui/components/panes/response-pane.tsx b/packages/insomnia/src/ui/components/panes/response-pane.tsx index 4d2fc7a94c..8781415462 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/response-pane.tsx @@ -6,6 +6,7 @@ import { Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-aria-components'; import { useRootLoaderData } from '~/root'; import { AnalyticsEvent } from '~/ui/analytics'; +import { bodyBufferToUtf8 } from '~/utils/utf8-bytes'; import { getSetCookieHeaders } from '../../../common/misc'; import { cancelRequestById } from '../../../network/cancellation'; @@ -219,7 +220,7 @@ export const ResponsePane: FC = ({ activeRequestId }) => { copyToClipboard={async () => { const bodyBuffer = activeResponse ? await services.helpers.getResponseBodyBuffer(activeResponse) : null; if (bodyBuffer) { - window.clipboard.writeText(bodyBuffer.toString('utf8')); + window.clipboard.writeText(bodyBufferToUtf8(bodyBuffer)); } }} /> diff --git a/packages/insomnia/src/ui/components/viewers/response-csv-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-csv-viewer.tsx index 8335caf198..d483c734c5 100644 --- a/packages/insomnia/src/ui/components/viewers/response-csv-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-csv-viewer.tsx @@ -1,8 +1,10 @@ import Papa from 'papaparse'; import React, { type FC, useEffect, useRef, useState } from 'react'; +import { utf8StringFromBytes } from '~/utils/utf8-bytes'; + interface Props { - body: Buffer; + body: Uint8Array; } export const ResponseCSVViewer: FC = ({ body }) => { @@ -10,7 +12,7 @@ export const ResponseCSVViewer: FC = ({ body }) => { const tableRef = useRef(null); useEffect(() => { - Papa.parse(body.toString('utf8'), { + Papa.parse(utf8StringFromBytes(body), { skipEmptyLines: true, complete: result => { setCSV(result); diff --git a/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx index abf24c6c55..9ab68dd35e 100644 --- a/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx @@ -6,6 +6,7 @@ import React, { type FC, useCallback, useEffect, useState } from 'react'; import { Button } from 'react-aria-components'; import type { Part } from '~/main/multipart-buffer-to-array'; +import { utf8StringFromBytes } from '~/utils/utf8-bytes'; import { Dropdown, DropdownItem, ItemContent } from '../base/dropdown'; import { showModal } from '../modals/index'; @@ -16,7 +17,7 @@ import { ResponseViewer } from './response-viewer'; interface Props { download: (...args: any[]) => any; responseId: string; - bodyBuffer: Buffer | null; + bodyBuffer: Uint8Array | null; contentType: string; disableHtmlPreviewJs: boolean; disablePreviewLinks: boolean; @@ -105,7 +106,7 @@ export const ResponseMultipartViewer: FC = ({ try { await window.main.writeFile({ path: filePath, - content: selectedPart.value.toString('utf8'), + content: utf8StringFromBytes(selectedPart.value), }); } catch (err) { console.warn('Failed to save multipart to file', err); @@ -198,7 +199,7 @@ export const ResponseMultipartViewer: FC = ({ error={null} filter={filter} filterHistory={filterHistory} - bodyBuffer={Buffer.from(selectedPart?.value || '')} + bodyBuffer={selectedPart?.value ?? new Uint8Array(0)} key={`${responseId}::${selectedPart?.id}`} previewMode={PREVIEW_MODE_FRIENDLY} responseId={`${responseId}[${selectedPart?.id}]`} diff --git a/packages/insomnia/src/ui/components/viewers/response-pdf-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-pdf-viewer.tsx index 8e8321c9d0..f690e236f1 100644 --- a/packages/insomnia/src/ui/components/viewers/response-pdf-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-pdf-viewer.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; interface Props { - body: Buffer; + body: Uint8Array; } export const ResponsePDFViewer = ({ body }: Props) => { diff --git a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx index d57b4324d5..b91549ca62 100644 --- a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx @@ -3,6 +3,7 @@ import { Fragment, useCallback, useRef, useState } from 'react'; import { AnalyticsEvent } from '~/ui/analytics'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; +import { bytesToBase64, utf8StringFromBytes } from '~/utils/utf8-bytes'; import { HUGE_RESPONSE_MB, LARGE_RESPONSE_MB } from '../../../common/constants'; import { unescapeForwardSlash } from '../../../common/misc'; @@ -59,8 +60,8 @@ export interface ResponseViewerProps { editorFontSize: number; filter: string; filterHistory: string[]; - bodyBuffer?: Buffer; - getBody?: (...args: any[]) => Promise; + bodyBuffer?: Uint8Array; + getBody?: (...args: any[]) => Promise; previewMode: string; responseId: string; url: string; @@ -90,7 +91,7 @@ export const ResponseViewer = ({ const [blockingBecauseTooLarge, setBlockingBecauseTooLarge] = useState(!alwaysShowLargeResponses && largeResponse); const [parseError, setParseError] = useState(''); - const [overSizedBody, setOversizedBody] = useState(bodyBuffer || null); + const [overSizedBody, setOversizedBody] = useState(bodyBuffer || null); const editorRef = useRef(null); @@ -99,9 +100,12 @@ export const ResponseViewer = ({ try { const buffer = await getBody?.(); - const bufferOrError = typeof buffer === 'string' ? Buffer.from(buffer) : buffer; + if (typeof buffer === 'string') { + setParseError(`Failed reading response from filesystem: ${buffer}`); + return setOversizedBody(null); + } - return setOversizedBody(bufferOrError || null); + return setOversizedBody(buffer || null); } catch (err) { setParseError(`Failed reading response from filesystem: ${err.stack}`); } @@ -137,7 +141,7 @@ export const ResponseViewer = ({ // Apparently users often send JSON with weird content-types like text/plain. try { if (overSizedBody && overSizedBody.length > 0) { - JSON.parse(overSizedBody.toString('utf8')); + JSON.parse(utf8StringFromBytes(overSizedBody)); return 'application/json'; } } catch {} @@ -145,9 +149,7 @@ export const ResponseViewer = ({ // It is fairly common for webservers to send errors in HTML by default. // NOTE: This will probably never throw but I'm not 100% so wrap anyway try { - const isProbablyHTML = overSizedBody - .slice(0, 100) - .toString() + const isProbablyHTML = utf8StringFromBytes(overSizedBody.slice(0, 100)) .trim() .match(/^/i); @@ -172,7 +174,7 @@ export const ResponseViewer = ({ return new TextDecoder(label).decode(overSizedBody); } catch (err) { console.warn('[response] Failed to decode body', err); - return overSizedBody.toString(); + return utf8StringFromBytes(overSizedBody); } }, [overSizedBody, _getContentType]); @@ -274,7 +276,7 @@ export const ResponseViewer = ({ if (previewMode === PREVIEW_MODE_FRIENDLY && contentType.indexOf('image/') === 0) { const justContentType = contentType.split(';')[0]; - const base64Body = overSizedBody.toString('base64'); + const base64Body = bytesToBase64(overSizedBody); return (
    @@ -339,7 +341,7 @@ export const ResponseViewer = ({ if (previewMode === PREVIEW_MODE_FRIENDLY && contentType.indexOf('audio/') === 0) { const justContentType = contentType.split(';')[0]; - const base64Body = overSizedBody.toString('base64'); + const base64Body = bytesToBase64(overSizedBody); return (