fix: remove Buffer class usage in renderer code (#10031)

(cherry picked from commit 4bc34bba99)
This commit is contained in:
Ryan Willis
2026-06-05 08:50:13 -07:00
committed by Insomnia
parent f156539bae
commit fef67da951
26 changed files with 174 additions and 96 deletions

View File

@@ -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

View File

@@ -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 });

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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.',

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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]);

View File

@@ -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] };
}

View File

@@ -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 = (

View File

@@ -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}>

View File

@@ -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)) || '',
});
}}
/>

View File

@@ -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);
});
});

View File

@@ -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) });
}

View File

@@ -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));
}
}}
/>

View File

@@ -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);

View File

@@ -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}]`}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
interface Props {
body: Buffer;
body: Uint8Array;
}
export const ResponsePDFViewer = ({ body }: Props) => {

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);
},
},
);

View 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);
});
});

View 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);
}