mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 11:52:33 -04:00
fix: remove Buffer class usage in renderer code (#10031)
(cherry picked from commit 4bc34bba99)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -204,8 +204,8 @@ export interface RendererToMainBridgeAPI {
|
||||
installPlugin: typeof installPlugin;
|
||||
initializeWorkspaceBackendProject: typeof initializeWorkspaceBackendProject;
|
||||
parseImport: typeof convert;
|
||||
multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise<Part[]>;
|
||||
writeFile: (options: { path: string; content: string | Buffer }) => Promise<string>;
|
||||
multipartBufferToArray: (options: { bodyBuffer: Uint8Array | null; contentType: string }) => Promise<Part[]>;
|
||||
writeFile: (options: { path: string; content: string | Uint8Array }) => Promise<string>;
|
||||
deleteCompiledRuleset: (options: { projectId: string }) => Promise<void>;
|
||||
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 });
|
||||
|
||||
@@ -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<Part[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<T>(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<T>(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;
|
||||
|
||||
@@ -41,9 +41,8 @@ export const PromptButton = <T,>({
|
||||
// Create flag to store the state value.
|
||||
const [state, setState] = useState<PromptStateEnum>('default');
|
||||
|
||||
// Timeout instancies
|
||||
const doneTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const triggerTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const doneTimeout = useRef<number | null>(null);
|
||||
const triggerTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -59,9 +58,7 @@ export const PromptButton = <T,>({
|
||||
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 = <T,>({
|
||||
// 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 = <T,>({
|
||||
setState('done');
|
||||
})
|
||||
.finally(() => {
|
||||
triggerTimeout.current = global.setTimeout(() => {
|
||||
triggerTimeout.current = window.setTimeout(() => {
|
||||
setState('default');
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
@@ -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<Props> = ({ 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]);
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
|
||||
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 = (
|
||||
<div className="relative flex h-full w-full flex-1 px-2">
|
||||
@@ -291,7 +292,7 @@ export const KeyValueEditor: FC<Props> = ({
|
||||
{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<Props> = ({
|
||||
{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 = (
|
||||
|
||||
@@ -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<Props> = ({
|
||||
|
||||
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 (
|
||||
<li onKeyDown={onKeydown} onClick={onClick} className={classes}>
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
</DropdownItem>
|
||||
@@ -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)) || '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({ 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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<Props> = ({ body }) => {
|
||||
@@ -10,7 +12,7 @@ export const ResponseCSVViewer: FC<Props> = ({ body }) => {
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Papa.parse<string[]>(body.toString('utf8'), {
|
||||
Papa.parse<string[]>(utf8StringFromBytes(body), {
|
||||
skipEmptyLines: true,
|
||||
complete: result => {
|
||||
setCSV(result);
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
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}]`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
body: Buffer;
|
||||
body: Uint8Array;
|
||||
}
|
||||
|
||||
export const ResponsePDFViewer = ({ body }: Props) => {
|
||||
|
||||
@@ -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<Buffer | string>;
|
||||
bodyBuffer?: Uint8Array;
|
||||
getBody?: (...args: any[]) => Promise<Uint8Array | string>;
|
||||
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<Buffer | null>(bodyBuffer || null);
|
||||
const [overSizedBody, setOversizedBody] = useState<Uint8Array | null>(bodyBuffer || null);
|
||||
|
||||
const editorRef = useRef<CodeEditorHandle>(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(/^<!doctype html.*>/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 (
|
||||
<div className="scrollable-container tall wide">
|
||||
<div className="scrollable">
|
||||
@@ -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 (
|
||||
<div className="vertically-center" key={responseId}>
|
||||
<audio controls>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { type FC, useCallback, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor';
|
||||
import { utf8StringFromBytes } from '~/utils/utf8-bytes';
|
||||
|
||||
import type { CurlEvent, CurlMessageEvent } from '../../../main/network/curl';
|
||||
import type { SocketIOEvent } from '../../../main/network/socket-io';
|
||||
@@ -23,7 +24,7 @@ export const MessageEventView: FC<Props<CurlMessageEvent | WebSocketMessageEvent
|
||||
// Best effort to parse the binary data as a string
|
||||
try {
|
||||
if ('data' in event && typeof event.data === 'object' && 'data' in event.data && Array.isArray(event.data.data)) {
|
||||
raw = Buffer.from(event.data.data).toString();
|
||||
raw = utf8StringFromBytes(new Uint8Array(event.data.data));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse event data to string, defaulting to JSON.stringify', err);
|
||||
|
||||
@@ -8,17 +8,8 @@ export const servicesProxy = new Proxy({} as Services, {
|
||||
{},
|
||||
{
|
||||
get(_target, methodName: string) {
|
||||
return async (...args: unknown[]) => {
|
||||
const result = await invokeWithNormalizedError<any>('services.invoke', serviceName, methodName, ...args);
|
||||
// contextBridge serializes Node.js Buffer as Uint8Array; the main process wraps
|
||||
// Buffer results with { __type: 'Buffer', data } so we can safely reconstruct here
|
||||
// without misidentifying genuine Uint8Array returns.
|
||||
// TODO: remove once service methods stop returning Buffer (tracked for deprecation).
|
||||
if (result && typeof result === 'object' && result.__type === 'Buffer' && Array.isArray(result.data)) {
|
||||
return Buffer.from(result.data);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return (...args: unknown[]) =>
|
||||
invokeWithNormalizedError<any>('services.invoke', serviceName, methodName, ...args);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
37
packages/insomnia/src/utils/utf8-bytes.test.ts
Normal file
37
packages/insomnia/src/utils/utf8-bytes.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { base64ToUtf8, bytesToBase64, latin1BytesFromString, utf8ByteLength, utf8ToBase64 } from './utf8-bytes';
|
||||
|
||||
describe('utf8 byte helpers', () => {
|
||||
it('roundtrips UTF-8 strings through base64', () => {
|
||||
const value = 'caf\u00E9 \u2603';
|
||||
|
||||
expect(base64ToUtf8(utf8ToBase64(value))).toBe(value);
|
||||
});
|
||||
|
||||
it('encodes arbitrary bytes to base64', () => {
|
||||
expect(bytesToBase64(new Uint8Array([0, 65, 127, 128, 159, 255]))).toBe('AEF/gJ//');
|
||||
});
|
||||
|
||||
it('counts utf8 byte length', () => {
|
||||
expect(utf8ByteLength('hello')).toBe(5);
|
||||
expect(utf8ByteLength('é')).toBe(2);
|
||||
});
|
||||
|
||||
it('encodes latin1 bytes', () => {
|
||||
expect(Array.from(latin1BytesFromString('password-é'))).toEqual([
|
||||
0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x2d, 0xe9,
|
||||
]);
|
||||
});
|
||||
|
||||
it('encodes byte arrays larger than one chunk', () => {
|
||||
const bytes = new Uint8Array(0x90_00);
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = index % 256;
|
||||
}
|
||||
|
||||
const decoded = Uint8Array.from(atob(bytesToBase64(bytes)), c => c.codePointAt(0) ?? 0);
|
||||
|
||||
expect(decoded).toEqual(bytes);
|
||||
});
|
||||
});
|
||||
48
packages/insomnia/src/utils/utf8-bytes.ts
Normal file
48
packages/insomnia/src/utils/utf8-bytes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const BASE64_CHUNK_SIZE = 0x80_00;
|
||||
|
||||
export function utf8BytesFromString(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
export function utf8ByteLength(value: string): number {
|
||||
return utf8BytesFromString(value).length;
|
||||
}
|
||||
|
||||
export function utf8StringFromBytes(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export function latin1BytesFromString(value: string): Uint8Array {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
// eslint-disable-next-line unicorn/prefer-code-point -- charCodeAt for latin1 for accuracy
|
||||
bytes[i] = value.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function utf8ToBase64(value: string): string {
|
||||
return bytesToBase64(utf8BytesFromString(value));
|
||||
}
|
||||
|
||||
export function base64ToUtf8(base64: string): string {
|
||||
return utf8StringFromBytes(Uint8Array.from(atob(base64), c => c.codePointAt(0) ?? 0));
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
|
||||
chunks.push(String.fromCodePoint(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)));
|
||||
}
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
|
||||
export function bodyBufferToUtf8(body: Uint8Array | string | null | undefined): string {
|
||||
if (body == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
return utf8StringFromBytes(body);
|
||||
}
|
||||
Reference in New Issue
Block a user