electron v15 pre-upgrade refactoring (#4524)

* now with 100% fat free cancellation

Co-authored-by: James Gatz <jamesgatzos@gmail.com>

* unblock electron 15

* fix cookielist and temp fix curl types

* fix types

* fix inso

* default to verbose inso test

* implement readdata function

* fix test

* revert test changes

* isomorphic cancel

* reduce typing issues

* curl types

* turns out the tests were wrong

* handle errors

* remove unused inso mock

* remove request delay

* fix lint and add logs

* Revert "remove request delay"

This reverts commit f07d8c90a7a7279ca10f8a8de1ea0c82caa06390.

* simplify and add cancel fallback

* skip cancel test

* playwright is fast and insomnia is slow

* trailing spaces are serious yo

* cancel is flake town

* hmm

* unblock nunjucks and storeTimeline

* fix nunjucks tests

* preload writeFile

* oops forgot to remove the reload

* debugging CI takes all day, log stuff and pray

* also warn if nunjucks is being lame

* Stop using environment variables

* revert debugging logs

Co-authored-by: James Gatz <jamesgatzos@gmail.com>
Co-authored-by: David Marby <david@dmarby.se>
This commit is contained in:
Jack Kavanagh 2022-03-03 14:42:04 +01:00 committed by GitHub
parent 82c37d7f96
commit 8585eea9e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 507 additions and 424 deletions

View File

@ -49,15 +49,11 @@ describe('render tests', () => {
expect(rendered).toBe('Hello FooBar!'); expect(rendered).toBe('Hello FooBar!');
}); });
it('fails on invalid template', async () => { it('returns invalid template', async () => {
try { const rendered = await renderUtils.render('Hello {{ msg }!', {
await renderUtils.render('Hello {{ msg }!', {
msg: 'World', msg: 'World',
}); });
fail('Render should have failed'); expect(rendered).toBe('Hello {{ msg }!');
} catch (err) {
expect(err.message).toContain('expected variable end');
}
}); });
it('handles variables using tag before tag is defined as expected (incorrect order)', async () => { it('handles variables using tag before tag is defined as expected (incorrect order)', async () => {
@ -570,7 +566,7 @@ describe('render tests', () => {
); );
fail('Render should have failed'); fail('Render should have failed');
} catch (err) { } catch (err) {
expect(err.message).toContain('expected variable end'); expect(err.message).toContain('attempted to output null or undefined value');
} }
}); });

View File

@ -1,6 +1,5 @@
import fuzzysort from 'fuzzysort'; import fuzzysort from 'fuzzysort';
import { join as pathJoin } from 'path'; import { join as pathJoin } from 'path';
import { Readable, Writable } from 'stream';
import * as uuid from 'uuid'; import * as uuid from 'uuid';
import zlib from 'zlib'; import zlib from 'zlib';
@ -341,27 +340,6 @@ export function fuzzyMatchAll(
}; };
} }
export async function waitForStreamToFinish(stream: Readable | Writable) {
return new Promise<void>(resolve => {
// @ts-expect-error -- access of internal values that are intended to be private. We should _not_ do this.
if (stream._readableState?.finished) {
return resolve();
}
// @ts-expect-error -- access of internal values that are intended to be private. We should _not_ do this.
if (stream._writableState?.finished) {
return resolve();
}
stream.on('close', () => {
resolve();
});
stream.on('error', () => {
resolve();
});
});
}
export function chunkArray<T>(arr: T[], chunkSize: number) { export function chunkArray<T>(arr: T[], chunkSize: number) {
const chunks: T[][] = []; const chunks: T[][] = [];

View File

@ -27,6 +27,20 @@ interface Window {
authorizeUserInWindow: (options: { url: string; urlSuccessRegex?: RegExp; urlFailureRegex?: RegExp; sessionId: string }) => Promise<string>; authorizeUserInWindow: (options: { url: string; urlSuccessRegex?: RegExp; urlFailureRegex?: RegExp; sessionId: string }) => Promise<string>;
setMenuBarVisibility: (visible: boolean) => void; setMenuBarVisibility: (visible: boolean) => void;
installPlugin: (url: string) => void; installPlugin: (url: string) => void;
writeFile: (options: {path: string; content: string}) => Promise<string>;
cancelCurlRequest: (requestId: string) => void;
curlRequest: (options: {
curlOptions: CurlOpt[];
responseBodyPath: string;
maxTimelineDataSizeKB: number;
requestId: string;
requestBodyPath?: string;
isMultipart: boolean;
}) => Promise<{
patch: ResponsePatch;
debugTimeline: ResponseTimelineEntry[];
headerResults: HeaderResult[];
}>;
}; };
dialog: { dialog: {
showOpenDialog: (options: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>; showOpenDialog: (options: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>;

View File

@ -1,6 +1,7 @@
import * as electron from 'electron'; import * as electron from 'electron';
import contextMenu from 'electron-context-menu'; import contextMenu from 'electron-context-menu';
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'; import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer';
import { writeFile } from 'fs';
import path from 'path'; import path from 'path';
import appConfig from '../config/config.json'; import appConfig from '../config/config.json';
@ -17,6 +18,7 @@ import * as updates from './main/updates';
import * as windowUtils from './main/window-utils'; import * as windowUtils from './main/window-utils';
import * as models from './models/index'; import * as models from './models/index';
import type { Stats } from './models/stats'; import type { Stats } from './models/stats';
import { cancelCurlRequest, curlRequest } from './network/libcurl-promise';
import { authorizeUserInWindow } from './network/o-auth-2/misc'; import { authorizeUserInWindow } from './network/o-auth-2/misc';
import installPlugin from './plugins/install'; import installPlugin from './plugins/install';
import type { ToastNotification } from './ui/components/toast'; import type { ToastNotification } from './ui/components/toast';
@ -259,6 +261,25 @@ async function _trackStats() {
return authorizeUserInWindow({ url, urlSuccessRegex, urlFailureRegex, sessionId }); return authorizeUserInWindow({ url, urlSuccessRegex, urlFailureRegex, sessionId });
}); });
ipcMain.handle('writeFile', (_, options) => {
return new Promise<string>((resolve, reject) => {
writeFile(options.path, options.content, err => {
if (err != null) {
return reject(err);
}
resolve(options.path);
});
});
});
ipcMain.handle('curlRequest', (_, options) => {
return curlRequest(options);
});
ipcMain.on('cancelCurlRequest', (_, requestId: string): void => {
cancelCurlRequest(requestId);
});
ipcMain.once('window-ready', () => { ipcMain.once('window-ready', () => {
const { currentVersion, launches, lastVersion } = stats; const { currentVersion, launches, lastVersion } = stats;

View File

@ -1,4 +1,3 @@
import { Curl } from '@getinsomnia/node-libcurl';
import electron, { BrowserWindow, MenuItemConstructorOptions } from 'electron'; import electron, { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import fs from 'fs'; import fs from 'fs';
import * as os from 'os'; import * as os from 'os';
@ -22,9 +21,6 @@ import * as log from '../common/log';
import LocalStorage from './local-storage'; import LocalStorage from './local-storage';
const { app, Menu, shell, dialog, clipboard } = electron; const { app, Menu, shell, dialog, clipboard } = electron;
// So we can use native modules in renderer
// NOTE: This was (deprecated in Electron 10)[https://github.com/electron/electron/issues/18397] and (removed in Electron 14)[https://github.com/electron/electron/pull/26874]
app.allowRendererProcessReuse = false;
const DEFAULT_WIDTH = 1280; const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720; const DEFAULT_HEIGHT = 720;
@ -388,7 +384,6 @@ export function createWindow() {
`Node: ${process.versions.node}`, `Node: ${process.versions.node}`,
`V8: ${process.versions.v8}`, `V8: ${process.versions.v8}`,
`Architecture: ${process.arch}`, `Architecture: ${process.arch}`,
`node-libcurl: ${Curl.getVersion()}`,
].join('\n'); ].join('\n');
const msgBox = await dialog.showMessageBox({ const msgBox = await dialog.showMessageBox({

View File

@ -1,4 +1,5 @@
import { CurlHttpVersion } from '@getinsomnia/node-libcurl'; import { CurlHttpVersion } from '@getinsomnia/node-libcurl/dist/enum/CurlHttpVersion';
import { CurlNetrc } from '@getinsomnia/node-libcurl/dist/enum/CurlNetrc';
import electron from 'electron'; import electron from 'electron';
import fs from 'fs'; import fs from 'fs';
import { HttpVersions } from 'insomnia-common'; import { HttpVersions } from 'insomnia-common';
@ -17,6 +18,7 @@ import {
import { filterHeaders } from '../../common/misc'; import { filterHeaders } from '../../common/misc';
import { getRenderedRequestAndContext } from '../../common/render'; import { getRenderedRequestAndContext } from '../../common/render';
import * as models from '../../models'; import * as models from '../../models';
import { _parseHeaders } from '../libcurl-promise';
import { DEFAULT_BOUNDARY } from '../multipart'; import { DEFAULT_BOUNDARY } from '../multipart';
import * as networkUtils from '../network'; import * as networkUtils from '../network';
window.app = electron.app; window.app = electron.app;
@ -604,7 +606,7 @@ describe('actuallySend()', () => {
NOPROGRESS: true, NOPROGRESS: true,
PROXY: '', PROXY: '',
TIMEOUT_MS: 0, TIMEOUT_MS: 0,
NETRC: 'Required', NETRC: CurlNetrc.Required,
URL: '', URL: '',
USERAGENT: `insomnia/${getAppVersion()}`, USERAGENT: `insomnia/${getAppVersion()}`,
VERBOSE: true, VERBOSE: true,
@ -736,7 +738,7 @@ describe('actuallySend()', () => {
...settings, ...settings,
preferredHttpVersion: HttpVersions.V1_0, preferredHttpVersion: HttpVersions.V1_0,
}); });
expect(JSON.parse(String(models.response.getBodyBuffer(responseV1))).options.HTTP_VERSION).toBe('V1_0'); expect(JSON.parse(String(models.response.getBodyBuffer(responseV1))).options.HTTP_VERSION).toBe(1);
expect(networkUtils.getHttpVersion(HttpVersions.V1_0).curlHttpVersion).toBe(CurlHttpVersion.V1_0); expect(networkUtils.getHttpVersion(HttpVersions.V1_0).curlHttpVersion).toBe(CurlHttpVersion.V1_0);
expect(networkUtils.getHttpVersion(HttpVersions.V1_1).curlHttpVersion).toBe(CurlHttpVersion.V1_1); expect(networkUtils.getHttpVersion(HttpVersions.V1_1).curlHttpVersion).toBe(CurlHttpVersion.V1_1);
expect(networkUtils.getHttpVersion(HttpVersions.V2PriorKnowledge).curlHttpVersion).toBe(CurlHttpVersion.V2PriorKnowledge); expect(networkUtils.getHttpVersion(HttpVersions.V2PriorKnowledge).curlHttpVersion).toBe(CurlHttpVersion.V2PriorKnowledge);
@ -745,48 +747,6 @@ describe('actuallySend()', () => {
expect(networkUtils.getHttpVersion(HttpVersions.default).curlHttpVersion).toBe(undefined); expect(networkUtils.getHttpVersion(HttpVersions.default).curlHttpVersion).toBe(undefined);
expect(networkUtils.getHttpVersion('blah').curlHttpVersion).toBe(undefined); expect(networkUtils.getHttpVersion('blah').curlHttpVersion).toBe(undefined);
}); });
it('requests can be cancelled by requestId', async () => {
// GIVEN
const workspace = await models.workspace.create();
const settings = await models.settings.getOrCreate();
const request1 = Object.assign(models.request.init(), {
_id: 'req_15',
parentId: workspace._id,
url: 'http://unix:3000/requestA',
method: 'GET',
});
const request2 = Object.assign(models.request.init(), {
_id: 'req_10',
parentId: workspace._id,
url: 'http://unix:3000/requestB',
method: 'GET',
});
const renderedRequest1 = await getRenderedRequest({ request: request1 });
const renderedRequest2 = await getRenderedRequest({ request: request2 });
// WHEN
const response1Promise = networkUtils._actuallySend(
renderedRequest1,
workspace,
settings,
);
const response2Promise = networkUtils._actuallySend(
renderedRequest2,
workspace,
settings,
);
await networkUtils.cancelRequestById(renderedRequest1._id);
const response1 = await response1Promise;
const response2 = await response2Promise;
// THEN
expect(response1.statusMessage).toBe('Cancelled');
expect(response2.statusMessage).toBe('OK');
expect(networkUtils.hasCancelFunctionForId(request1._id)).toBe(false);
expect(networkUtils.hasCancelFunctionForId(request2._id)).toBe(false);
});
}); });
describe('_getAwsAuthHeaders', () => { describe('_getAwsAuthHeaders', () => {
@ -888,7 +848,7 @@ describe('_parseHeaders', () => {
const minimalHeaders = ['HTTP/1.1 301', '']; const minimalHeaders = ['HTTP/1.1 301', ''];
it('Parses single response headers', () => { it('Parses single response headers', () => {
expect(networkUtils._parseHeaders(Buffer.from(basicHeaders.join('\n')))).toEqual([ expect(_parseHeaders(Buffer.from(basicHeaders.join('\n')))).toEqual([
{ {
code: 301, code: 301,
version: 'HTTP/1.1', version: 'HTTP/1.1',
@ -936,7 +896,7 @@ describe('_parseHeaders', () => {
}); });
it('Parses Windows newlines', () => { it('Parses Windows newlines', () => {
expect(networkUtils._parseHeaders(Buffer.from(basicHeaders.join('\r\n')))).toEqual([ expect(_parseHeaders(Buffer.from(basicHeaders.join('\r\n')))).toEqual([
{ {
code: 301, code: 301,
version: 'HTTP/1.1', version: 'HTTP/1.1',
@ -985,7 +945,7 @@ describe('_parseHeaders', () => {
it('Parses multiple responses', () => { it('Parses multiple responses', () => {
const blobs = basicHeaders.join('\r\n') + '\n' + minimalHeaders.join('\n'); const blobs = basicHeaders.join('\r\n') + '\n' + minimalHeaders.join('\n');
expect(networkUtils._parseHeaders(Buffer.from(blobs))).toEqual([ expect(_parseHeaders(Buffer.from(blobs))).toEqual([
{ {
code: 301, code: 301,
version: 'HTTP/1.1', version: 'HTTP/1.1',

View File

@ -0,0 +1,241 @@
// NOTE: this file should not be imported by electron renderer because node-libcurl is not-context-aware
// Related issue https://github.com/JCMais/node-libcurl/issues/155
if (process.type === 'renderer') throw new Error('node-libcurl unavailable in renderer');
import { Curl, CurlCode, CurlFeature, CurlInfoDebug } from '@getinsomnia/node-libcurl';
import fs from 'fs';
import { Readable, Writable } from 'stream';
import { ValueOf } from 'type-fest';
import { describeByteSize } from '../common/misc';
import { ResponseHeader } from '../models/response';
import { ResponsePatch } from './network';
// wraps libcurl with a promise taking curl options and others required by read, write and debug callbacks
// returning a response patch, debug timeline and list of headers for each redirect
interface CurlOpt {
key: Parameters<Curl['setOpt']>[0];
value: Parameters<Curl['setOpt']>[1];
}
interface CurlRequestOptions {
curlOptions: CurlOpt[];
responseBodyPath: string;
maxTimelineDataSizeKB: number;
requestId: string; // for cancellation
requestBodyPath?: string; // only used for POST file path
isMultipart: boolean; // for clean up after implemention side effect
}
interface ResponseTimelineEntry {
name: ValueOf<typeof LIBCURL_DEBUG_MIGRATION_MAP>;
timestamp: number;
value: string;
}
interface CurlRequestOutput {
patch: ResponsePatch;
debugTimeline: ResponseTimelineEntry[];
headerResults: HeaderResult[];
}
// NOTE: this is a dictionary of functions to close open listeners
const cancelCurlRequestHandlers = {};
export const cancelCurlRequest = id => cancelCurlRequestHandlers[id]();
export const curlRequest = (options: CurlRequestOptions) => new Promise<CurlRequestOutput>(async resolve => {
try {
// Create instance and handlers, poke value options in, set up write and debug callbacks, listen for events
const { curlOptions, responseBodyPath, requestBodyPath, maxTimelineDataSizeKB, requestId, isMultipart } = options;
const curl = new Curl();
let requestFileDescriptor;
const responseBodyWriteStream = fs.createWriteStream(responseBodyPath);
// cancel request by id map
cancelCurlRequestHandlers[requestId] = () => {
if (requestFileDescriptor && responseBodyPath) {
closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath);
}
curl.close();
};
// set the string and number options from network.ts
curlOptions.forEach(opt => curl.setOpt(opt.key, opt.value));
// read file into request and close file desriptor
if (requestBodyPath) {
requestFileDescriptor = fs.openSync(requestBodyPath, 'r');
curl.setOpt(Curl.option.READDATA, requestFileDescriptor);
curl.on('end', () => closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath));
curl.on('error', () => closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath));
}
// set up response writer
let responseBodyBytes = 0;
curl.setOpt(Curl.option.WRITEFUNCTION, buffer => {
responseBodyBytes += buffer.length;
responseBodyWriteStream.write(buffer);
return buffer.length;
});
// set up response logger
const debugTimeline: ResponseTimelineEntry[] = [];
curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => {
const rawName = Object.keys(CurlInfoDebug).find(k => CurlInfoDebug[k] === infoType) || '';
const infoTypeName = LIBCURL_DEBUG_MIGRATION_MAP[rawName] || rawName;
const isSSLData = infoType === CurlInfoDebug.SslDataIn || infoType === CurlInfoDebug.SslDataOut;
const isEmpty = buffer.length === 0;
// Don't show cookie setting because this will display every domain in the jar
const isAddCookie = infoType === CurlInfoDebug.Text && buffer.toString('utf8').indexOf('Added cookie') === 0;
if (isSSLData || isEmpty || isAddCookie) {
return 0;
}
let value;
if (infoType === CurlInfoDebug.DataOut) {
// Ignore the possibly large data messages
const lessThan10KB = buffer.length / 1024 < maxTimelineDataSizeKB || 10;
value = lessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`;
}
if (infoType === CurlInfoDebug.DataIn) {
value = `Received ${describeByteSize(buffer.length)} chunk`;
}
debugTimeline.push({
name: infoType === CurlInfoDebug.DataIn ? 'TEXT' : infoTypeName,
value: value || buffer.toString('utf8'),
timestamp: Date.now(),
});
return 0; // Must be here
});
// makes rawHeaders a buffer, rather than HeaderInfo[]
curl.enable(CurlFeature.Raw);
// NOTE: legacy write end callback
curl.on('end', () => responseBodyWriteStream.end());
curl.on('end', async (_1, _2, rawHeaders: Buffer) => {
const patch = {
bytesContent: responseBodyBytes,
bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD) as number,
elapsedTime: curl.getInfo(Curl.info.TOTAL_TIME) as number * 1000,
url: curl.getInfo(Curl.info.EFFECTIVE_URL) as string,
};
curl.close();
await waitForStreamToFinish(responseBodyWriteStream);
const headerResults = _parseHeaders(rawHeaders);
resolve({ patch, debugTimeline, headerResults });
});
// NOTE: legacy write end callback
curl.on('error', () => responseBodyWriteStream.end());
curl.on('error', async function(err, code) {
const elapsedTime = curl.getInfo(Curl.info.TOTAL_TIME) as number * 1000;
curl.close();
await waitForStreamToFinish(responseBodyWriteStream);
let error = err + '';
let statusMessage = 'Error';
if (code === CurlCode.CURLE_ABORTED_BY_CALLBACK) {
error = 'Request aborted';
statusMessage = 'Abort';
}
const patch = {
statusMessage,
error: error || 'Something went wrong',
elapsedTime,
};
// NOTE: legacy, default headerResults
resolve({ patch, debugTimeline, headerResults: [{ version: '', code: -1, reason: '', headers: [] }] });
});
curl.perform();
} catch (e) {
const patch = {
statusMessage: 'Error',
error: e.message || 'Something went wrong',
elapsedTime: 0,
};
resolve({ patch, debugTimeline: [], headerResults: [{ version: '', code: -1, reason: '', headers: [] }] });
}
});
const closeReadFunction = (fd: number, isMultipart: boolean, path?: string) => {
fs.closeSync(fd);
// NOTE: multipart files are combined before sending, so this file is deleted after
// alt implemention to send one part at a time https://github.com/JCMais/node-libcurl/blob/develop/examples/04-multi.js
if (isMultipart && path) fs.unlink(path, () => { });
};
// Because node-libcurl changed some names that we used in the timeline
const LIBCURL_DEBUG_MIGRATION_MAP = {
HeaderIn: 'HEADER_IN',
DataIn: 'DATA_IN',
SslDataIn: 'SSL_DATA_IN',
HeaderOut: 'HEADER_OUT',
DataOut: 'DATA_OUT',
SslDataOut: 'SSL_DATA_OUT',
Text: 'TEXT',
'': '',
};
interface HeaderResult {
headers: ResponseHeader[];
version: string;
code: number;
reason: string;
}
// NOTE: legacy, has tests, could be simplified
export function _parseHeaders(buffer: Buffer) {
const results: HeaderResult[] = [];
const lines = buffer.toString('utf8').split(/\r?\n|\r/g);
for (let i = 0, currentResult: HeaderResult | null = null; i < lines.length; i++) {
const line = lines[i];
const isEmptyLine = line.trim() === '';
// If we hit an empty line, start parsing the next response
if (isEmptyLine && currentResult) {
results.push(currentResult);
currentResult = null;
continue;
}
if (!currentResult) {
const [version, code, ...other] = line.split(/ +/g);
currentResult = {
version,
code: parseInt(code, 10),
reason: other.join(' '),
headers: [],
};
} else {
const [name, value] = line.split(/:\s(.+)/);
const header: ResponseHeader = {
name,
value: value || '',
};
currentResult.headers.push(header);
}
}
return results;
}
// NOTE: legacy, suspicious, could be simplified
async function waitForStreamToFinish(stream: Readable | Writable) {
return new Promise<void>(resolve => {
// @ts-expect-error -- access of internal values that are intended to be private. We should _not_ do this.
if (stream._readableState?.finished) {
return resolve();
}
// @ts-expect-error -- access of internal values that are intended to be private. We should _not_ do this.
if (stream._writableState?.finished) {
return resolve();
}
stream.on('close', () => {
resolve();
});
stream.on('error', () => {
resolve();
});
});
}

View File

@ -1,15 +1,8 @@
import { import { CurlAuth } from '@getinsomnia/node-libcurl/dist/enum/CurlAuth';
Curl, import { CurlHttpVersion } from '@getinsomnia/node-libcurl/dist/enum/CurlHttpVersion';
CurlAuth, import { CurlNetrc } from '@getinsomnia/node-libcurl/dist/enum/CurlNetrc';
CurlCode,
CurlFeature,
CurlHttpVersion,
CurlInfoDebug,
CurlNetrc,
} from '@getinsomnia/node-libcurl';
import aws4 from 'aws4'; import aws4 from 'aws4';
import clone from 'clone'; import clone from 'clone';
import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { HttpVersions } from 'insomnia-common'; import { HttpVersions } from 'insomnia-common';
import { cookiesFromJar, jarFromCookies } from 'insomnia-cookies'; import { cookiesFromJar, jarFromCookies } from 'insomnia-cookies';
@ -38,7 +31,6 @@ import { database as db } from '../common/database';
import { getDataDirectory, getTempDir } from '../common/electron-helpers'; import { getDataDirectory, getTempDir } from '../common/electron-helpers';
import { import {
delay, delay,
describeByteSize,
getContentTypeHeader, getContentTypeHeader,
getHostHeader, getHostHeader,
getLocationHeader, getLocationHeader,
@ -49,7 +41,6 @@ import {
hasContentTypeHeader, hasContentTypeHeader,
hasUserAgentHeader, hasUserAgentHeader,
LIBCURL_DEBUG_MIGRATION_MAP, LIBCURL_DEBUG_MIGRATION_MAP,
waitForStreamToFinish,
} from '../common/misc'; } from '../common/misc';
import type { ExtraRenderInfo, RenderedRequest } from '../common/render'; import type { ExtraRenderInfo, RenderedRequest } from '../common/render';
import { import {
@ -70,6 +61,48 @@ import caCerts from './ca-certs';
import { buildMultipart } from './multipart'; import { buildMultipart } from './multipart';
import { urlMatchesCertHost } from './url-matches-cert-host'; import { urlMatchesCertHost } from './url-matches-cert-host';
// Based on list of option properties but with callback options removed
const Curl = {
option: {
ACCEPT_ENCODING: 'ACCEPT_ENCODING',
CAINFO: 'CAINFO',
COOKIE: 'COOKIE',
COOKIEFILE: 'COOKIEFILE',
COOKIELIST: 'COOKIELIST',
CUSTOMREQUEST: 'CUSTOMREQUEST',
FOLLOWLOCATION: 'FOLLOWLOCATION',
HTTPAUTH: 'HTTPAUTH',
HTTPGET: 'HTTPGET',
HTTPHEADER: 'HTTPHEADER',
HTTPPOST: 'HTTPPOST',
HTTP_VERSION: 'HTTP_VERSION',
INFILESIZE_LARGE: 'INFILESIZE_LARGE',
KEYPASSWD: 'KEYPASSWD',
MAXREDIRS: 'MAXREDIRS',
NETRC: 'NETRC',
NOBODY: 'NOBODY',
NOPROGRESS: 'NOPROGRESS',
NOPROXY: 'NOPROXY',
PASSWORD: 'PASSWORD',
POST: 'POST',
POSTFIELDS: 'POSTFIELDS',
PATH_AS_IS: 'PATH_AS_IS',
PROXY: 'PROXY',
PROXYAUTH: 'PROXYAUTH',
SSLCERT: 'SSLCERT',
SSLCERTTYPE: 'SSLCERTTYPE',
SSLKEY: 'SSLKEY',
SSL_VERIFYHOST: 'SSL_VERIFYHOST',
SSL_VERIFYPEER: 'SSL_VERIFYPEER',
TIMEOUT_MS: 'TIMEOUT_MS',
UNIX_SOCKET_PATH: 'UNIX_SOCKET_PATH',
UPLOAD: 'UPLOAD',
URL: 'URL',
USERAGENT: 'USERAGENT',
USERNAME: 'USERNAME',
VERBOSE: 'VERBOSE',
},
};
export interface ResponsePatch { export interface ResponsePatch {
bodyCompression?: 'zip' | null; bodyCompression?: 'zip' | null;
bodyPath?: string; bodyPath?: string;
@ -121,27 +154,13 @@ export const getHttpVersion = preferredHttpVersion => {
}; };
export async function cancelRequestById(requestId) { export async function cancelRequestById(requestId) {
if (hasCancelFunctionForId(requestId)) { const hasCancelFunction = cancelRequestFunctionMap.hasOwnProperty(requestId) && typeof cancelRequestFunctionMap[requestId] === 'function';
const cancelRequestFunction = cancelRequestFunctionMap[requestId]; if (hasCancelFunction) {
return cancelRequestFunctionMap[requestId]();
if (typeof cancelRequestFunction === 'function') {
return cancelRequestFunction();
} }
}
console.log(`[network] Failed to cancel req=${requestId} because cancel function not found`); console.log(`[network] Failed to cancel req=${requestId} because cancel function not found`);
} }
function clearCancelFunctionForId(requestId) {
if (hasCancelFunctionForId(requestId)) {
delete cancelRequestFunctionMap[requestId];
}
}
export function hasCancelFunctionForId(requestId) {
return cancelRequestFunctionMap.hasOwnProperty(requestId);
}
export async function _actuallySend( export async function _actuallySend(
renderedRequest: RenderedRequest, renderedRequest: RenderedRequest,
workspace: Workspace, workspace: Workspace,
@ -162,17 +181,17 @@ export async function _actuallySend(
const addTimelineText = addTimelineItem(LIBCURL_DEBUG_MIGRATION_MAP.Text); const addTimelineText = addTimelineItem(LIBCURL_DEBUG_MIGRATION_MAP.Text);
// Initialize the curl handle
const curl = new Curl();
/** Helper function to respond with a success */ /** Helper function to respond with a success */
async function respond( async function respond(
patch: ResponsePatch, patch: ResponsePatch,
bodyPath: string | null, bodyPath: string | null,
debugTimeline: any[] = []
) { ) {
const timelinePath = await storeTimeline(timeline); const timelinePath = await storeTimeline([...timeline, ...debugTimeline]);
// Tear Down the cancellation logic // Tear Down the cancellation logic
clearCancelFunctionForId(renderedRequest._id); if (cancelRequestFunctionMap.hasOwnProperty(renderedRequest._id)) {
delete cancelRequestFunctionMap[renderedRequest._id];
}
const environmentId = environment ? environment._id : null; const environmentId = environment ? environment._id : null;
return resolve(Object.assign( return resolve(Object.assign(
{ {
@ -191,6 +210,7 @@ export async function _actuallySend(
/** Helper function to respond with an error */ /** Helper function to respond with an error */
async function handleError(err: Error) { async function handleError(err: Error) {
await respond( await respond(
{ {
url: renderedRequest.url, url: renderedRequest.url,
@ -204,34 +224,32 @@ export async function _actuallySend(
null, null,
); );
} }
// NOTE: can have duplicate keys because of cookie options
/** Helper function to set Curl options */ const curlOptions: { key: string; value: string | string[] | number | boolean }[] = [];
const setOpt: typeof curl.setOpt = (opt: any, val: any) => { const setOpt = (key: string, value: string | string[] | number | boolean) => {
try { curlOptions.push({ key, value });
return curl.setOpt(opt, val);
} catch (err) {
const name = Object.keys(Curl.option).find(name => Curl.option[name] === opt);
throw new Error(`${err.message} (${opt} ${name || 'n/a'})`);
}
}; };
try { try {
// Setup the cancellation logic // Setup the cancellation logic
cancelRequestFunctionMap[renderedRequest._id] = async () => { cancelRequestFunctionMap[renderedRequest._id] = async () => {
await respond( await respond(
{ {
elapsedTime: (curl.getInfo(Curl.info.TOTAL_TIME) as number || 0) * 1000, elapsedTime: 0,
// @ts-expect-error -- needs generic bytesRead: 0,
bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD), url: renderedRequest.url,
// @ts-expect-error -- needs generic
url: curl.getInfo(Curl.info.EFFECTIVE_URL),
statusMessage: 'Cancelled', statusMessage: 'Cancelled',
error: 'Request was cancelled', error: 'Request was cancelled',
}, },
null, null,
); );
// Kill it! // NOTE: conditionally use ipc bridge, renderer cannot import native modules directly
curl.close(); const nodejsCancelCurlRequest = process.type === 'renderer'
? window.main.cancelCurlRequest
// eslint-disable-next-line @typescript-eslint/no-var-requires
: require('./libcurl-promise').cancelCurlRequest;
nodejsCancelCurlRequest(renderedRequest._id);
}; };
// Set all the basic options // Set all the basic options
@ -243,9 +261,6 @@ export async function _actuallySend(
// True so curl doesn't print progress // True so curl doesn't print progress
setOpt(Curl.option.ACCEPT_ENCODING, ''); setOpt(Curl.option.ACCEPT_ENCODING, '');
// Auto decode everything
curl.enable(CurlFeature.Raw);
// Set follow redirects setting // Set follow redirects setting
switch (renderedRequest.settingFollowRedirects) { switch (renderedRequest.settingFollowRedirects) {
case 'off': case 'off':
@ -292,44 +307,6 @@ export async function _actuallySend(
break; break;
} }
// Setup debug handler
setOpt(Curl.option.DEBUGFUNCTION, (infoType, contentBuffer) => {
const content = contentBuffer.toString('utf8');
const rawName = Object.keys(CurlInfoDebug).find(k => CurlInfoDebug[k] === infoType) || '';
const name = LIBCURL_DEBUG_MIGRATION_MAP[rawName] || rawName;
const addToTimeline = addTimelineItem(name);
if (infoType === CurlInfoDebug.SslDataIn || infoType === CurlInfoDebug.SslDataOut) {
return 0;
}
// Ignore the possibly large data messages
if (infoType === CurlInfoDebug.DataOut) {
if (contentBuffer.length === 0) {
// Sometimes this happens, but I'm not sure why. Just ignore it.
} else if (contentBuffer.length / 1024 < settings.maxTimelineDataSizeKB) {
addToTimeline(content);
} else {
addToTimeline(`(${describeByteSize(contentBuffer.length)} hidden)`);
}
return 0;
}
if (infoType === CurlInfoDebug.DataIn) {
addTimelineText(`Received ${describeByteSize(contentBuffer.length)} chunk`);
return 0;
}
// Don't show cookie setting because this will display every domain in the jar
if (infoType === CurlInfoDebug.Text && content.indexOf('Added cookie') === 0) {
return 0;
}
addToTimeline(content);
return 0; // Must be here
});
// Set the headers (to be modified as we go) // Set the headers (to be modified as we go)
const headers = clone(renderedRequest.headers); const headers = clone(renderedRequest.headers);
// Set the URL, including the query parameters // Set the URL, including the query parameters
@ -352,7 +329,6 @@ export async function _actuallySend(
addTimelineText('Preparing request to ' + finalUrl); addTimelineText('Preparing request to ' + finalUrl);
addTimelineText('Current time is ' + new Date().toISOString()); addTimelineText('Current time is ' + new Date().toISOString());
addTimelineText(`Using ${Curl.getVersion()}`);
const httpVersion = getHttpVersion(settings.preferredHttpVersion); const httpVersion = getHttpVersion(settings.preferredHttpVersion);
addTimelineText(httpVersion.log); addTimelineText(httpVersion.log);
@ -523,7 +499,8 @@ export async function _actuallySend(
let noBody = false; let noBody = false;
let requestBody: string | null = null; let requestBody: string | null = null;
const expectsBody = ['POST', 'PUT', 'PATCH'].includes(renderedRequest.method.toUpperCase()); const expectsBody = ['POST', 'PUT', 'PATCH'].includes(renderedRequest.method.toUpperCase());
let requestBodyPath;
let isMultipart = false;
if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_URLENCODED) { if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_URLENCODED) {
requestBody = buildQueryStringFromParams(renderedRequest.body.params || [], false); requestBody = buildQueryStringFromParams(renderedRequest.body.params || [], false);
} else if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_DATA) { } else if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_DATA) {
@ -531,6 +508,8 @@ export async function _actuallySend(
const { filePath: multipartBodyPath, boundary, contentLength } = await buildMultipart( const { filePath: multipartBodyPath, boundary, contentLength } = await buildMultipart(
params, params,
); );
requestBodyPath = multipartBodyPath;
isMultipart = true;
// Extend the Content-Type header // Extend the Content-Type header
const contentTypeHeader = getContentTypeHeader(headers); const contentTypeHeader = getContentTypeHeader(headers);
@ -543,36 +522,18 @@ export async function _actuallySend(
}); });
} }
const fd = fs.openSync(multipartBodyPath, 'r');
setOpt(Curl.option.INFILESIZE_LARGE, contentLength); setOpt(Curl.option.INFILESIZE_LARGE, contentLength);
setOpt(Curl.option.UPLOAD, 1); setOpt(Curl.option.UPLOAD, 1);
setOpt(Curl.option.READDATA, fd);
// We need this, otherwise curl will send it as a PUT // We need this, otherwise curl will send it as a PUT
setOpt(Curl.option.CUSTOMREQUEST, renderedRequest.method); setOpt(Curl.option.CUSTOMREQUEST, renderedRequest.method);
const fn = () => {
fs.closeSync(fd);
fs.unlink(multipartBodyPath, () => {
// Pass
});
};
curl.on('end', fn);
curl.on('error', fn);
} else if (renderedRequest.body.fileName) { } else if (renderedRequest.body.fileName) {
const { size } = fs.statSync(renderedRequest.body.fileName); const { size } = fs.statSync(renderedRequest.body.fileName);
const fileName = renderedRequest.body.fileName || ''; requestBodyPath = renderedRequest.body.fileName || '';
const fd = fs.openSync(fileName, 'r');
setOpt(Curl.option.INFILESIZE_LARGE, size); setOpt(Curl.option.INFILESIZE_LARGE, size);
setOpt(Curl.option.UPLOAD, 1); setOpt(Curl.option.UPLOAD, 1);
setOpt(Curl.option.READDATA, fd);
// We need this, otherwise curl will send it as a POST // We need this, otherwise curl will send it as a POST
setOpt(Curl.option.CUSTOMREQUEST, renderedRequest.method); setOpt(Curl.option.CUSTOMREQUEST, renderedRequest.method);
const fn = () => fs.closeSync(fd);
curl.on('end', fn);
curl.on('error', fn);
} else if (typeof renderedRequest.body.mimeType === 'string' || expectsBody) { } else if (typeof renderedRequest.body.mimeType === 'string' || expectsBody) {
requestBody = renderedRequest.body.text || ''; requestBody = renderedRequest.body.text || '';
} else { } else {
@ -697,39 +658,36 @@ export async function _actuallySend(
} }
}); });
setOpt(Curl.option.HTTPHEADER, headerStrings); setOpt(Curl.option.HTTPHEADER, headerStrings);
let responseBodyBytes = 0;
const responsesDir = pathJoin(getDataDirectory(), 'responses'); const responsesDir = pathJoin(getDataDirectory(), 'responses');
mkdirp.sync(responsesDir); mkdirp.sync(responsesDir);
const responseBodyPath = pathJoin(responsesDir, uuid.v4() + '.response'); const responseBodyPath = pathJoin(responsesDir, uuid.v4() + '.response');
const responseBodyWriteStream = fs.createWriteStream(responseBodyPath); // NOTE: conditionally use ipc bridge, renderer cannot import native modules directly
curl.on('end', () => responseBodyWriteStream.end()); const nodejsCurlRequest = process.type === 'renderer'
curl.on('error', () => responseBodyWriteStream.end()); ? window.main.curlRequest
setOpt(Curl.option.WRITEFUNCTION, buff => { // eslint-disable-next-line @typescript-eslint/no-var-requires
responseBodyBytes += buff.length; : require('./libcurl-promise').curlRequest;
responseBodyWriteStream.write(buff); const requestOptions = {
return buff.length; curlOptions,
}); responseBodyPath,
// Handle the response ending requestBodyPath,
curl.on('end', async (_1, _2, rawHeaders: Buffer) => { isMultipart,
const allCurlHeadersObjects = _parseHeaders(rawHeaders); maxTimelineDataSizeKB: settings.maxTimelineDataSizeKB,
requestId: renderedRequest._id,
};
const { patch, debugTimeline, headerResults } = await nodejsCurlRequest(requestOptions);
// Headers are an array (one for each redirect) // Headers are an array (one for each redirect)
const lastCurlHeadersObject = allCurlHeadersObjects[allCurlHeadersObjects.length - 1]; const lastCurlHeadersObject = headerResults[headerResults.length - 1];
// Collect various things
const httpVersion = lastCurlHeadersObject.version || '';
const statusCode = lastCurlHeadersObject.code || -1;
const statusMessage = lastCurlHeadersObject.reason || '';
// Collect the headers
const headers = lastCurlHeadersObject.headers;
// Calculate the content type // Calculate the content type
const contentTypeHeader = getContentTypeHeader(headers); const contentTypeHeader = getContentTypeHeader(lastCurlHeadersObject.headers);
const contentType = contentTypeHeader ? contentTypeHeader.value : '';
// Update Cookie Jar // Update Cookie Jar
let currentUrl = finalUrl; let currentUrl = finalUrl;
let setCookieStrings: string[] = []; let setCookieStrings: string[] = [];
const jar = jarFromCookies(renderedRequest.cookieJar.cookies); const jar = jarFromCookies(renderedRequest.cookieJar.cookies);
for (const { headers } of allCurlHeadersObjects) { for (const { headers } of headerResults) {
// Collect Set-Cookie headers // Collect Set-Cookie headers
const setCookieHeaders = getSetCookieHeaders(headers); const setCookieHeaders = getSetCookieHeaders(headers);
setCookieStrings = [...setCookieStrings, ...setCookieHeaders.map(h => h.value)]; setCookieStrings = [...setCookieStrings, ...setCookieHeaders.map(h => h.value)];
@ -769,46 +727,17 @@ export async function _actuallySend(
} }
} }
// Return the response data
const responsePatch: ResponsePatch = { const responsePatch: ResponsePatch = {
contentType, contentType: contentTypeHeader ? contentTypeHeader.value : '',
headers, headers: lastCurlHeadersObject.headers,
httpVersion, httpVersion: lastCurlHeadersObject.version,
statusCode, statusCode: lastCurlHeadersObject.code,
statusMessage, statusMessage: lastCurlHeadersObject.reason,
bytesContent: responseBodyBytes, ...patch,
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD),
elapsedTime: curl.getInfo(Curl.info.TOTAL_TIME) as number * 1000,
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
url: curl.getInfo(Curl.info.EFFECTIVE_URL),
}; };
// Close the request
curl.close();
// Make sure the response body has been fully written first
await waitForStreamToFinish(responseBodyWriteStream);
// Send response
await respond(responsePatch, responseBodyPath);
});
curl.on('error', async function(err, code) {
let error = err + '';
let statusMessage = 'Error';
if (code === CurlCode.CURLE_ABORTED_BY_CALLBACK) { respond(responsePatch, responseBodyPath, debugTimeline);
error = 'Request aborted';
statusMessage = 'Abort';
}
await respond(
{
statusMessage,
error: error || 'Something went wrong',
elapsedTime: curl.getInfo(Curl.info.TOTAL_TIME) as number * 1000,
},
null,
);
});
curl.perform();
} catch (err) { } catch (err) {
console.log('[network] Error', err); console.log('[network] Error', err);
await handleError(err); await handleError(err);
@ -820,12 +749,12 @@ export async function sendWithSettings(
requestId: string, requestId: string,
requestPatch: Record<string, any>, requestPatch: Record<string, any>,
) { ) {
console.log(`[network] Sending with settings req=${requestId}`);
const request = await models.request.getById(requestId); const request = await models.request.getById(requestId);
if (!request) { if (!request) {
throw new Error(`Failed to find request: ${requestId}`); throw new Error(`Failed to find request: ${requestId}`);
} }
const settings = await models.settings.getOrCreate(); const settings = await models.settings.getOrCreate();
const ancestors = await db.withAncestors(request, [ const ancestors = await db.withAncestors(request, [
models.request.type, models.request.type,
@ -851,7 +780,6 @@ export async function sendWithSettings(
request: RenderedRequest; request: RenderedRequest;
context: Record<string, any>; context: Record<string, any>;
}; };
try { try {
renderResult = await getRenderedRequestAndContext({ request: newRequest, environmentId }); renderResult = await getRenderedRequestAndContext({ request: newRequest, environmentId });
} catch (err) { } catch (err) {
@ -892,12 +820,12 @@ export async function send(
*/ */
const timeSinceLastInteraction = Date.now() - lastUserInteraction; const timeSinceLastInteraction = Date.now() - lastUserInteraction;
const delayMillis = Math.max(0, MAX_DELAY_TIME - timeSinceLastInteraction); const delayMillis = Math.max(0, MAX_DELAY_TIME - timeSinceLastInteraction);
if (delayMillis > 0) { if (delayMillis > 0) {
await delay(delayMillis); await delay(delayMillis);
} }
// Fetch some things // Fetch some things
const request = await models.request.getById(requestId); const request = await models.request.getById(requestId);
const settings = await models.settings.getOrCreate(); const settings = await models.settings.getOrCreate();
const ancestors = await db.withAncestors(request, [ const ancestors = await db.withAncestors(request, [
@ -919,6 +847,7 @@ export async function send(
extraInfo, extraInfo,
}, },
); );
const renderedRequestBeforePlugins = renderResult.request; const renderedRequestBeforePlugins = renderResult.request;
const renderedContextBeforePlugins = renderResult.context; const renderedContextBeforePlugins = renderResult.context;
const workspaceDoc = ancestors.find(isWorkspace); const workspaceDoc = ancestors.find(isWorkspace);
@ -931,6 +860,7 @@ export async function send(
let renderedRequest: RenderedRequest; let renderedRequest: RenderedRequest;
try { try {
console.log('[network] Apply plugin pre hooks');
renderedRequest = await _applyRequestPluginHooks( renderedRequest = await _applyRequestPluginHooks(
renderedRequestBeforePlugins, renderedRequestBeforePlugins,
renderedContextBeforePlugins, renderedContextBeforePlugins,
@ -955,6 +885,7 @@ export async function send(
environment, environment,
settings.validateSSL, settings.validateSSL,
); );
console.log( console.log(
response.error response.error
? `[network] Response failed req=${requestId} err=${response.error || 'n/a'}` ? `[network] Response failed req=${requestId} err=${response.error || 'n/a'}`
@ -1038,51 +969,6 @@ async function _applyResponsePluginHooks(
} }
interface HeaderResult {
headers: ResponseHeader[];
version: string;
code: number;
reason: string;
}
export function _parseHeaders(
buffer: Buffer,
) {
const results: HeaderResult[] = [];
const lines = buffer.toString('utf8').split(/\r?\n|\r/g);
for (let i = 0, currentResult: HeaderResult | null = null; i < lines.length; i++) {
const line = lines[i];
const isEmptyLine = line.trim() === '';
// If we hit an empty line, start parsing the next response
if (isEmptyLine && currentResult) {
results.push(currentResult);
currentResult = null;
continue;
}
if (!currentResult) {
const [version, code, ...other] = line.split(/ +/g);
currentResult = {
version,
code: parseInt(code, 10),
reason: other.join(' '),
headers: [],
};
} else {
const [name, value] = line.split(/:\s(.+)/);
const header: ResponseHeader = {
name,
value: value || '',
};
currentResult.headers.push(header);
}
}
return results;
}
// exported for unit tests only // exported for unit tests only
export function _getAwsAuthHeaders( export function _getAwsAuthHeaders(
credentials: { credentials: {
@ -1130,18 +1016,20 @@ export function _getAwsAuthHeaders(
} }
function storeTimeline(timeline: ResponseTimelineEntry[]) { function storeTimeline(timeline: ResponseTimelineEntry[]) {
return new Promise<string>((resolve, reject) => {
const timelineStr = JSON.stringify(timeline, null, '\t'); const timelineStr = JSON.stringify(timeline, null, '\t');
const timelineHash = crypto.createHash('sha1').update(timelineStr).digest('hex'); const timelineHash = uuid.v4();
const responsesDir = pathJoin(getDataDirectory(), 'responses'); const responsesDir = pathJoin(getDataDirectory(), 'responses');
mkdirp.sync(responsesDir); mkdirp.sync(responsesDir);
const timelinePath = pathJoin(responsesDir, timelineHash + '.timeline'); const timelinePath = pathJoin(responsesDir, timelineHash + '.timeline');
if (process.type === 'renderer'){
return window.main.writeFile({ path: timelinePath, content: timelineStr });
}
return new Promise<string>((resolve, reject) => {
fs.writeFile(timelinePath, timelineStr, err => { fs.writeFile(timelinePath, timelineStr, err => {
if (err != null) { if (err != null) {
reject(err); return reject(err);
} else {
resolve(timelinePath);
} }
resolve(timelinePath);
}); });
}); });
} }

View File

@ -5,6 +5,9 @@ const main = {
authorizeUserInWindow: options => ipcRenderer.invoke('authorizeUserInWindow', options), authorizeUserInWindow: options => ipcRenderer.invoke('authorizeUserInWindow', options),
setMenuBarVisibility: options => ipcRenderer.send('setMenuBarVisibility', options), setMenuBarVisibility: options => ipcRenderer.send('setMenuBarVisibility', options),
installPlugin: options => ipcRenderer.invoke('installPlugin', options), installPlugin: options => ipcRenderer.invoke('installPlugin', options),
curlRequest: options => ipcRenderer.invoke('curlRequest', options),
cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options),
writeFile: options => ipcRenderer.invoke('writeFile', options),
}; };
const dialog = { const dialog = {
showOpenDialog: options => ipcRenderer.invoke('showOpenDialog', options), showOpenDialog: options => ipcRenderer.invoke('showOpenDialog', options),

View File

@ -44,6 +44,10 @@ export function render(
renderMode?: string; renderMode?: string;
} = {}, } = {},
) { ) {
const hasNunjucksInterpolationSymbols = text.includes('{{') && text.includes('}}');
const hasNunjucksCustomTagSymbols = text.includes('{%') && text.includes('%}');
const hasNunjucksCommentSymbols = text.includes('{#') && text.includes('#}');
if (!hasNunjucksInterpolationSymbols && !hasNunjucksCustomTagSymbols && !hasNunjucksCommentSymbols) return text;
const context = config.context || {}; const context = config.context || {};
// context needs to exist on the root for the old templating syntax, and in _ for the new templating syntax // context needs to exist on the root for the old templating syntax, and in _ for the new templating syntax
// old: {{ arr[0].prop }} // old: {{ arr[0].prop }}
@ -52,9 +56,13 @@ export function render(
const path = config.path || null; const path = config.path || null;
const renderMode = config.renderMode || RENDER_ALL; const renderMode = config.renderMode || RENDER_ALL;
return new Promise<string | null>(async (resolve, reject) => { return new Promise<string | null>(async (resolve, reject) => {
// NOTE: this is added as a breadcrumb because renderString sometimes hangs
const id = setTimeout(() => console.log('Warning: nunjucks failed to respond within 5 seconds'), 5000);
const nj = await getNunjucks(renderMode); const nj = await getNunjucks(renderMode);
nj?.renderString(text, templatingContext, (err, result) => { nj?.renderString(text, templatingContext, (err, result) => {
clearTimeout(id);
if (err) { if (err) {
console.log(err);
const sanitizedMsg = err.message const sanitizedMsg = err.message
.replace(/\(unknown path\)\s/, '') .replace(/\(unknown path\)\s/, '')
.replace(/\[Line \d+, Column \d*]/, '') .replace(/\[Line \d+, Column \d*]/, '')

View File

@ -1,4 +1,3 @@
import { Curl } from '@getinsomnia/node-libcurl';
import { autoBindMethodsForReact } from 'class-autobind-decorator'; import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { HotKeyRegistry } from 'insomnia-common'; import { HotKeyRegistry } from 'insomnia-common';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
@ -20,7 +19,6 @@ import { ImportExport } from '../settings/import-export';
import { Plugins } from '../settings/plugins'; import { Plugins } from '../settings/plugins';
import { Shortcuts } from '../settings/shortcuts'; import { Shortcuts } from '../settings/shortcuts';
import { ThemePanel } from '../settings/theme-panel'; import { ThemePanel } from '../settings/theme-panel';
import { Tooltip } from '../tooltip';
import { showModal } from './index'; import { showModal } from './index';
export const TAB_INDEX_EXPORT = 1; export const TAB_INDEX_EXPORT = 1;
@ -80,9 +78,6 @@ export class UnconnectedSettingsModal extends PureComponent<Props, State> {
{getAppName()} Preferences {getAppName()} Preferences
<span className="faint txt-sm"> <span className="faint txt-sm">
&nbsp;&nbsp;&nbsp; v{getAppVersion()} &nbsp;&nbsp;&nbsp; v{getAppVersion()}
<Tooltip position="bottom" message={Curl.getVersion()}>
<i className="fa fa-info-circle" />
</Tooltip>
{email ? ` ${email}` : null} {email ? ` ${email}` : null}
</span> </span>
</ModalHeader> </ModalHeader>

View File

@ -806,6 +806,7 @@ class App extends PureComponent<AppProps, State> {
try { try {
const responsePatch = await network.send(requestId, environmentId); const responsePatch = await network.send(requestId, environmentId);
await models.response.create(responsePatch, settings.maxHistoryResponses); await models.response.create(responsePatch, settings.maxHistoryResponses);
} catch (err) { } catch (err) {
if (err.type === 'render') { if (err.type === 'render') {
showModal(RequestRenderErrorModal, { showModal(RequestRenderErrorModal, {
@ -831,6 +832,7 @@ class App extends PureComponent<AppProps, State> {
await updateRequestMetaByParentId(requestId, { await updateRequestMetaByParentId(requestId, {
activeResponseId: null, activeResponseId: null,
}); });
// Stop loading // Stop loading
handleStopLoading(requestId); handleStopLoading(requestId);
} }

View File

@ -1,2 +1 @@
jest.mock('@getinsomnia/node-libcurl');
process.env.DEFAULT_APP_NAME = process.env.DEFAULT_APP_NAME || 'insomnia-app'; process.env.DEFAULT_APP_NAME = process.env.DEFAULT_APP_NAME || 'insomnia-app';

View File

@ -34,6 +34,7 @@ describe.each(compact([npmPackageBinPath, ...binaries]))('inso with %s', binPath
srcInsoNedb, srcInsoNedb,
['--env', 'Dev'], ['--env', 'Dev'],
'Echo Test Suite', 'Echo Test Suite',
'--verbose',
); );
expect(failed).toBe(false); expect(failed).toBe(false);

View File

@ -7,7 +7,7 @@ resources:
parentId: fld_0e50ba4426bb4540ade91e0525ea1f29 parentId: fld_0e50ba4426bb4540ade91e0525ea1f29
modified: 1645664215605 modified: 1645664215605
created: 1645544268127 created: 1645544268127
url: "{{ _.oidc_base_path }}/me" url: "http://127.0.0.1:4010/oidc/me"
name: No PKCE name: No PKCE
description: "" description: ""
method: GET method: GET
@ -15,13 +15,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_authorization_code }}" clientId: "authorization_code"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: authorization_code grantType: authorization_code
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid offline_access scope: openid offline_access
state: "" state: ""
@ -60,7 +60,7 @@ resources:
parentId: fld_0e50ba4426bb4540ade91e0525ea1f29 parentId: fld_0e50ba4426bb4540ade91e0525ea1f29
modified: 1645664217727 modified: 1645664217727
created: 1645220819802 created: 1645220819802
url: "{{ _.oidc_base_path }}/me" url: "http://127.0.0.1:4010/oidc/me"
name: PKCE SHA256 name: PKCE SHA256
description: "" description: ""
method: GET method: GET
@ -68,13 +68,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_authorization_code_pkce }}" clientId: "authorization_code_pkce"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: authorization_code grantType: authorization_code
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid offline_access scope: openid offline_access
state: "" state: ""
@ -93,7 +93,7 @@ resources:
parentId: fld_0e50ba4426bb4540ade91e0525ea1f29 parentId: fld_0e50ba4426bb4540ade91e0525ea1f29
modified: 1645664218264 modified: 1645664218264
created: 1645543526615 created: 1645543526615
url: "{{ _.oidc_base_path }}/me" url: "http://127.0.0.1:4010/oidc/me"
name: PKCE Plain name: PKCE Plain
description: "" description: ""
method: GET method: GET
@ -101,13 +101,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_authorization_code_pkce }}" clientId: "authorization_code_pkce"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: authorization_code grantType: authorization_code
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid offline_access scope: openid offline_access
state: "" state: ""
@ -128,7 +128,7 @@ resources:
parentId: fld_d34790add1584643b6688c3add5bbe85 parentId: fld_d34790add1584643b6688c3add5bbe85
modified: 1645664218947 modified: 1645664218947
created: 1645545802379 created: 1645545802379
url: "{{ _.oidc_base_path }}/id-token" url: "http://127.0.0.1:4010/oidc/id-token"
name: ID Token name: ID Token
description: "" description: ""
method: GET method: GET
@ -136,13 +136,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_implicit }}" clientId: "implicit"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: implicit grantType: implicit
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid scope: openid
state: "" state: ""
@ -173,7 +173,7 @@ resources:
parentId: fld_d34790add1584643b6688c3add5bbe85 parentId: fld_d34790add1584643b6688c3add5bbe85
modified: 1645664219446 modified: 1645664219446
created: 1645567186775 created: 1645567186775
url: "{{ _.oidc_base_path }}/me" url: "http://127.0.0.1:4010/oidc/me"
name: ID and Access Token name: ID and Access Token
description: "" description: ""
method: GET method: GET
@ -181,13 +181,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_implicit }}" clientId: "implicit"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: implicit grantType: implicit
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token token responseType: id_token token
scope: openid scope: openid
state: "" state: ""
@ -208,7 +208,7 @@ resources:
parentId: wrk_392055e2aa29457b9d2904396cd7631f parentId: wrk_392055e2aa29457b9d2904396cd7631f
modified: 1645664219861 modified: 1645664219861
created: 1645637343873 created: 1645637343873
url: "{{ _.oidc_base_path }}/client-credential" url: "http://127.0.0.1:4010/oidc/client-credential"
name: Client Credentials name: Client Credentials
description: "" description: ""
method: GET method: GET
@ -216,13 +216,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_client_creds }}" clientId: "client_credentials"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: client_credentials grantType: client_credentials
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid scope: openid
state: "" state: ""
@ -245,7 +245,7 @@ resources:
parentId: wrk_392055e2aa29457b9d2904396cd7631f parentId: wrk_392055e2aa29457b9d2904396cd7631f
modified: 1645664220407 modified: 1645664220407
created: 1645636233910 created: 1645636233910
url: "{{ _.oidc_base_path }}/me" url: "http://127.0.0.1:4010/oidc/me"
name: Resource Owner Password Credentials name: Resource Owner Password Credentials
description: "" description: ""
method: GET method: GET
@ -253,13 +253,13 @@ resources:
parameters: [] parameters: []
headers: [] headers: []
authentication: authentication:
accessTokenUrl: "{{ _.oidc_base_path }}/token" accessTokenUrl: "http://127.0.0.1:4010/oidc/token"
authorizationUrl: "{{ _.oidc_base_path }}/auth" authorizationUrl: "http://127.0.0.1:4010/oidc/auth"
clientId: "{{ _.client_id_resource_owner }}" clientId: "resource_owner"
clientSecret: "{{ _.client_secret }}" clientSecret: "secret"
disabled: false disabled: false
grantType: password grantType: password
redirectUrl: "{{ _.oidc_callback }}" redirectUrl: "http://127.0.0.1:4010/callback"
responseType: id_token responseType: id_token
scope: openid scope: openid
state: "" state: ""
@ -283,27 +283,8 @@ resources:
modified: 1645661876119 modified: 1645661876119
created: 1645220798237 created: 1645220798237
name: Base Environment name: Base Environment
data: data: {}
base_url: http://127.0.0.1:4010 dataPropertyOrder: null
oidc_base_path: "{{ _.base_url }}/oidc"
oidc_callback: "{{ _.base_url }}/callback"
client_id_authorization_code: authorization_code
client_id_authorization_code_pkce: authorization_code_pkce
client_id_implicit: implicit
client_id_client_creds: client_credentials
client_id_resource_owner: resource_owner
client_secret: secret
dataPropertyOrder:
"&":
- base_url
- oidc_base_path
- oidc_callback
- client_id_authorization_code
- client_id_authorization_code_pkce
- client_id_implicit
- client_id_client_creds
- client_id_resource_owner
- client_secret
color: null color: null
isPrivate: false isPrivate: false
metaSortKey: 1639556944617 metaSortKey: 1639556944617

View File

@ -57,7 +57,7 @@ export const test = baseTest.extend<{
page: async ({ app }, use) => { page: async ({ app }, use) => {
const page = await app.firstWindow(); const page = await app.firstWindow();
if (process.platform === 'win32') await page.reload(); await page.waitForLoadState();
await page.click("text=Don't share usage analytics"); await page.click("text=Don't share usage analytics");

View File

@ -59,7 +59,7 @@ test('can send requests', async ({ app, page }) => {
await expect(responseBody).toContainText('Set-Cookie: insomnia-test-cookie=value123'); await expect(responseBody).toContainText('Set-Cookie: insomnia-test-cookie=value123');
}); });
// This feature is unsafe to place beside other tests, cancelling a request causes node-libcurl to block // This feature is unsafe to place beside other tests, cancelling a request can cause network code to block
// related to https://linear.app/insomnia/issue/INS-973 // related to https://linear.app/insomnia/issue/INS-973
test('can cancel requests', async ({ app, page }) => { test('can cancel requests', async ({ app, page }) => {
await page.click('[data-testid="project"]'); await page.click('[data-testid="project"]');
@ -73,7 +73,8 @@ test('can cancel requests', async ({ app, page }) => {
await page.click('button:has-text("GETdelayed request")'); await page.click('button:has-text("GETdelayed request")');
await page.click('text=http://127.0.0.1:4010/delay/seconds/20Send >> button'); await page.click('text=http://127.0.0.1:4010/delay/seconds/20Send >> button');
await page.click('text=Loading...Cancel Request >> button');
await page.click('[data-testid="response-pane"] button:has-text("Cancel Request")');
await page.click('text=Request was cancelled'); await page.click('text=Request was cancelled');
}); });