SSE/EventStream support (#6147)

* new request

* add button

* s

* fix formatting issue

* detect event stream

* hide dropdown

* wip

* add basic test

* pass 1 can make connection

* implement event stream open pass 2

* wire up ready state

* wiring up close and error pass 3

* can open and close connections

* clean header

* listen to timeline

* extract options to function

* fix bug in ws

* add debug stuff to timeline

* don't rely on redux to set active response

* fix close type

* rename sse to event stream

* get request flag from request model

* copy websocket response pane

* hide response data

* flatten ws and curl responses

* fix test

* fix catch

* reset file

* use realtime event watcher

* rename some files

* fix types

* fix e2e test

* fix lint

* consistent empty states

* pin to bottom

* remove todo

* add SSE logo

* add sse to readme
This commit is contained in:
Jack Kavanagh 2023-07-19 11:58:37 +02:00 committed by GitHub
parent 45ee825087
commit 3cdd4c8491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 853 additions and 297 deletions

View File

@ -14,6 +14,6 @@
},
"files.insertFinalNewline": true,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "modificationsIfAvailable",
"editor.formatOnSaveMode": "file",
"editor.defaultFormatter": "vscode.typescript-language-features",
}

View File

@ -3,7 +3,7 @@
[![Slack Channel](https://chat.insomnia.rest/badge.svg)](https://chat.insomnia.rest/)
[![license](https://img.shields.io/github/license/Kong/insomnia.svg)](LICENSE)
Insomnia is an open-source, cross-platform API client for GraphQL, REST, WebSockets and gRPC.
Insomnia is an open-source, cross-platform API client for GraphQL, REST, WebSockets, Server-sent events and gRPC.
![Insomnia API Client](https://raw.githubusercontent.com/Kong/insomnia/develop/screenshots/main.png)

View File

@ -84,6 +84,31 @@ resources:
settingRebuildPath: true
settingFollowRedirects: global
_type: request
- _id: req_e6fab3568ed54f9d83c92e2c8006e150
parentId: wrk_5b5ab67830944ffcbec47528366ef403
modified: 1636141084436
created: 1636141078601
url: http://127.0.0.1:4010/events
name: connects to event stream and shows ping response
description: ""
method: GET
body: {}
parameters: []
headers:
- id: pair_4c3fe3092f1245eab6d960c633d8be8c
name: Accept
value: text/event-stream
description: ""
authentication: {}
metaSortKey: -1636141078601
isPrivate: false
settingStoreCookies: true
settingSendCookies: true
settingDisableRenderRequestBody: false
settingEncodeUrl: true
settingRebuildPath: true
settingFollowRedirects: global
_type: request
- _id: req_06a660bffe724e3fac14d7d09b4a5c9b
parentId: wrk_5b5ab67830944ffcbec47528366ef403
modified: 1636141067089
@ -197,11 +222,4 @@ resources:
settingRebuildPath: true
settingFollowRedirects: global
_type: request
- _id: spc_255904e6a8774d24b29c9c3718feb07f
parentId: wrk_5b5ab67830944ffcbec47528366ef403
modified: 1636140994428
created: 1636140994428
fileName: Smoke tests
contents: ""
contentType: yaml
_type: api_spec

View File

@ -1,8 +1,9 @@
import crypto from 'node:crypto';
import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import { readFileSync } from 'fs';
import { createServer } from 'https';
import crypto from 'node:crypto';
import { join } from 'path';
import { basicAuthRouter } from './basic-auth';
@ -77,6 +78,12 @@ app.get('/events', (request, response) => {
response,
};
subscribers.push(subscriber);
setInterval(() => {
// const id = subscriberId;
const data = JSON.stringify({ message: 'Time: ' + new Date().toISOString().slice(11, 19) });
// response.write('id: ' + id + '\n');
response.write('data: ' + data + '\n\n');
}, 1000);
request.on('close', () => {
console.log(`${subscriberId} Connection closed`);
subscribers = subscribers.filter(sub => sub.id !== subscriberId);

View File

@ -22,40 +22,47 @@ test('can send requests', async ({ app, page }) => {
await page.getByText('CollectionSmoke testsjust now').click();
await page.getByRole('button', { name: 'send JSON request' }).click();
await page.click('text=http://127.0.0.1:4010/pets/1Send >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
await expect(responseBody).toContainText('"id": "1"');
await page.getByRole('button', { name: 'Preview' }).click();
await page.getByRole('menuitem', { name: 'Raw Data' }).click();
await expect(responseBody).toContainText('{"id":"1"}');
await page.getByRole('button', { name: 'connects to event stream and shows ping response' }).click();
await page.getByTestId('request-pane').getByRole('button', { name: 'Connect' }).click();
await expect(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Timeline' }).click();
await expect(responseBody).toContainText('Connected to 127.0.0.1');
await page.getByTestId('request-pane').getByRole('button', { name: 'Disconnect' }).click();
await page.getByRole('button', { name: 'sends dummy.csv request and shows rich response' }).click();
await page.click('text=http://127.0.0.1:4010/file/dummy.csvSend >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
await page.getByRole('button', { name: 'Preview' }).click();
await page.getByRole('menuitem', { name: 'Raw Data' }).click();
await expect(responseBody).toContainText('a,b,c');
await page.getByRole('button', { name: 'sends dummy.xml request and shows raw response' }).click();
await page.click('text=http://127.0.0.1:4010/file/dummy.xmlSend >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
await expect(responseBody).toContainText('xml version="1.0"');
await expect(responseBody).toContainText('<LoginResult>');
await page.getByRole('button', { name: 'sends dummy.pdf request and shows rich response' }).click();
await page.click('text=http://127.0.0.1:4010/file/dummy.pdfSend >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
// TODO(filipe): re-add a check for the preview that is less flaky
await page.getByRole('tab', { name: 'Timeline' }).click();
await page.locator('pre').filter({ hasText: '< Content-Type: application/pdf' }).click();
await page.getByRole('button', { name: 'sends request with basic authentication' }).click();
await page.click('text=http://127.0.0.1:4010/auth/basicSend >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
await expect(responseBody).toContainText('basic auth received');
await page.getByRole('button', { name: 'sends request with cookie and get cookie in response' }).click();
await page.click('text=http://127.0.0.1:4010/cookiesSend >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Timeline' }).click();
await expect(responseBody).toContainText('Set-Cookie: insomnia-test-cookie=value123');
@ -76,7 +83,7 @@ test('can cancel requests', async ({ app, page }) => {
await page.getByText('CollectionSmoke testsjust now').click();
await page.getByRole('button', { name: 'delayed request' }).click();
await page.click('text=http://127.0.0.1:4010/delay/seconds/20Send >> button');
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Cancel Request' }).click();
await page.click('text=Request was cancelled');

View File

@ -253,6 +253,7 @@ export const CONTENT_TYPE_JSON = 'application/json';
export const CONTENT_TYPE_PLAINTEXT = 'text/plain';
export const CONTENT_TYPE_XML = 'application/xml';
export const CONTENT_TYPE_YAML = 'text/yaml';
export const CONTENT_TYPE_EVENT_STREAM = 'text/event-stream';
export const CONTENT_TYPE_EDN = 'application/edn';
export const CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const CONTENT_TYPE_FORM_DATA = 'multipart/form-data';

View File

@ -12,6 +12,7 @@ import { validateInsomniaConfig } from './common/validate-insomnia-config';
import { registerElectronHandlers } from './main/ipc/electron';
import { registergRPCHandlers } from './main/ipc/grpc';
import { registerMainHandlers } from './main/ipc/main';
import { registerCurlHandlers } from './main/network/curl';
import { registerWebSocketHandlers } from './main/network/websocket';
import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry';
import { checkIfRestartNeeded } from './main/squirrel-startup';
@ -70,6 +71,7 @@ app.on('ready', async () => {
registerMainHandlers();
registergRPCHandlers();
registerWebSocketHandlers();
registerCurlHandlers();
/**
* There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching.

View File

@ -13,6 +13,7 @@ import { exportAllWorkspaces } from '../export';
import { insomniaFetch } from '../insomniaFetch';
import installPlugin from '../install-plugin';
import { axiosRequest } from '../network/axios-request';
import { CurlBridgeAPI } from '../network/curl';
import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise';
import { WebSocketBridgeAPI } from '../network/websocket';
import { gRPCBridgeAPI } from './grpc';
@ -34,6 +35,7 @@ export interface MainBridgeAPI {
on: (channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void) => () => void;
webSocket: WebSocketBridgeAPI;
grpc: gRPCBridgeAPI;
curl: CurlBridgeAPI;
trackSegmentEvent: (options: { event: string; properties?: Record<string, unknown> }) => void;
trackPageView: (options: { name: string }) => void;
axiosRequest: typeof axiosRequest;

View File

@ -0,0 +1,358 @@
import { Readable } from 'node:stream';
import { Curl, CurlFeature, CurlInfoDebug, HeaderInfo } from '@getinsomnia/node-libcurl';
import electron, { ipcMain } from 'electron';
import fs from 'fs';
import path from 'path';
import tls from 'tls';
import { v4 as uuidV4 } from 'uuid';
import { describeByteSize, generateId, getSetCookieHeaders } from '../../common/misc';
import * as models from '../../models';
import { CookieJar } from '../../models/cookie-jar';
import { Environment } from '../../models/environment';
import { RequestAuthentication, RequestHeader } from '../../models/request';
import { Response } from '../../models/response';
import { addSetCookiesToToughCookieJar } from '../../network/network';
import { urlMatchesCertHost } from '../../network/url-matches-cert-host';
import { invariant } from '../../utils/invariant';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { createConfiguredCurlInstance } from './libcurl-promise';
export interface CurlConnection extends Curl {
_id: string;
requestId: string;
}
export interface CurlOpenEvent {
_id: string;
requestId: string;
type: 'open';
timestamp: number;
}
export interface CurlMessageEvent {
_id: string;
requestId: string;
type: 'message';
timestamp: number;
data: string;
direction: 'OUTGOING' | 'INCOMING';
}
export interface CurlErrorEvent {
_id: string;
requestId: string;
type: 'error';
timestamp: number;
message: string;
error: Error;
}
export interface CurlCloseEvent {
_id: string;
requestId: string;
type: 'close';
timestamp: number;
statusCode: number;
reason: string;
wasClean: boolean;
code: number;
}
export type CurlEvent =
| CurlOpenEvent
| CurlMessageEvent
| CurlErrorEvent
| CurlCloseEvent;
export type CurlEventLog = CurlEvent[];
const CurlConnections = new Map<string, Curl>();
const eventLogFileStreams = new Map<string, fs.WriteStream>();
const timelineFileStreams = new Map<string, fs.WriteStream>();
const parseHeadersAndBuildTimeline = (url: string, headersWithStatus: HeaderInfo) => {
const { result, ...headers } = headersWithStatus;
const statusMessage = result?.reason || '';
const statusCode = result?.code || 0;
const httpVersion = result?.version;
const responseHeaders = Object.entries(headers).map(([name, value]) => ({ name, value: value?.toString() || '' }));
const timeline = [
{ value: `Preparing request to ${url}`, name: 'Text', timestamp: Date.now() },
];
return { timeline, responseHeaders, statusCode, statusMessage, httpVersion };
};
interface OpenCurlRequestOptions {
requestId: string;
workspaceId: string;
url: string;
headers: RequestHeader[];
authentication: RequestAuthentication;
cookieJar: CookieJar;
initialPayload?: string;
}
const openCurlConnection = async (
event: Electron.IpcMainInvokeEvent,
options: OpenCurlRequestOptions
): Promise<void> => {
const existingConnection = CurlConnections.get(options.requestId);
if (existingConnection) {
console.warn('Connection still open to ' + existingConnection.getInfo(Curl.info.EFFECTIVE_URL));
return;
}
const request = await models.request.getById(options.requestId);
const responseId = generateId('res');
if (!request) {
console.warn('Could not find request for ' + options.requestId);
return;
}
const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses');
fs.mkdirSync(responsesDir, { recursive: true });
const responseBodyPath = path.join(responsesDir, uuidV4() + '.response');
eventLogFileStreams.set(options.requestId, fs.createWriteStream(responseBodyPath));
const timelinePath = path.join(responsesDir, responseId + '.timeline');
timelineFileStreams.set(options.requestId, fs.createWriteStream(timelinePath));
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(options.workspaceId);
const environmentId: string = workspaceMeta.activeEnvironmentId || 'n/a';
const environment: Environment | null = await models.environment.getById(environmentId || 'n/a');
const responseEnvironmentId = environment ? environment._id : null;
const caCert = await models.caCertificate.findByParentId(options.workspaceId);
const caCertficatePath = caCert?.path;
// attempt to read CA Certificate PEM from disk, fallback to root certificates
const caCertificate = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n');
try {
if (!options.url) {
throw new Error('URL is required');
}
const readyStateChannel = `curl.${request._id}.readyState`;
const settings = await models.settings.getOrCreate();
const start = performance.now();
const clientCertificates = await models.clientCertificate.findByParentId(options.workspaceId);
const filteredClientCertificates = clientCertificates.filter(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, 'https:'), options.url));
const { curl, debugTimeline } = createConfiguredCurlInstance({
// TODO: get cookies here
req: { ...request, cookieJar: options.cookieJar, cookies: [] },
finalUrl: options.url,
settings,
caCert: caCertificate,
certificates: filteredClientCertificates,
});
debugTimeline.forEach(entry => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(entry) + '\n'));
curl.enable(CurlFeature.StreamResponse);
curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => {
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;
}
// NOTE: resolves "Text" from CurlInfoDebug[CurlInfoDebug.Text]
const name = CurlInfoDebug[infoType] as keyof typeof CurlInfoDebug;
let timelineMessage;
const isRequestData = infoType === CurlInfoDebug.DataOut;
if (isRequestData) {
// Ignore large post data messages
const isLessThan10KB = buffer.length / 1024 < (settings.maxTimelineDataSizeKB || 1);
timelineMessage = isLessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`;
}
const isResponseData = infoType === CurlInfoDebug.DataIn;
if (!isResponseData) {
const value = timelineMessage || buffer.toString('utf8');
timelineFileStreams.get(options.requestId)?.write(JSON.stringify({ name, value, timestamp: Date.now() }) + '\n');
}
return 0;
});
curl.on('error', async (error, errorCode) => {
const errorEvent: CurlErrorEvent = {
_id: uuidV4(),
requestId: options.requestId,
message: error.message,
type: 'error',
error,
timestamp: Date.now(),
};
console.error('curl - error: ', error, errorCode);
deleteRequestMaps(request._id, error.message, errorEvent);
event.sender.send(readyStateChannel, false);
curl.close();
if (errorCode) {
const res = await models.response.getById(responseId);
if (!res) {
createErrorResponse(responseId, request._id, responseEnvironmentId, timelinePath, error.message || 'Something went wrong');
}
}
});
curl.on('stream', async (stream: Readable, _code: number, [headersWithStatus]: HeaderInfo[]) => {
event.sender.send(readyStateChannel, true);
const { timeline, responseHeaders, statusCode, statusMessage, httpVersion } = parseHeadersAndBuildTimeline(options.url, headersWithStatus);
const responsePatch: Partial<Response> = {
_id: responseId,
parentId: request._id,
environmentId: responseEnvironmentId,
headers: responseHeaders,
url: options.url,
statusCode,
statusMessage,
httpVersion,
elapsedTime: performance.now() - start,
timelinePath,
bodyPath: responseBodyPath,
settingSendCookies: request.settingSendCookies,
settingStoreCookies: request.settingStoreCookies,
bodyCompression: null,
};
const settings = await models.settings.getOrCreate();
const res = await models.response.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id });
if (request.settingStoreCookies) {
const setCookieStrings: string[] = getSetCookieHeaders(responseHeaders).map(h => h.value);
const totalSetCookies = setCookieStrings.length;
if (totalSetCookies) {
const currentUrl = request.url;
const { cookies, rejectedCookies } = await addSetCookiesToToughCookieJar({ setCookieStrings, currentUrl, cookieJar: options.cookieJar });
rejectedCookies.forEach(errorMessage => timeline.push({ value: `Rejected cookie: ${errorMessage}`, name: 'Text', timestamp: Date.now() }));
const hasCookiesToPersist = totalSetCookies > rejectedCookies.length;
if (hasCookiesToPersist) {
await models.cookieJar.update(options.cookieJar, { cookies });
timeline.push({ value: `Saved ${totalSetCookies} cookies`, name: 'Text', timestamp: Date.now() });
}
}
}
timeline.map(t => timelineFileStreams.get(options.requestId)?.write(JSON.stringify(t) + '\n'));
// we are going to write the response stream to this file
const writableStream = eventLogFileStreams.get(request._id);
// two ways to go here
invariant(writableStream, 'writableStream should be defined');
for await (const chunk of stream) {
const messageEvent: CurlMessageEvent = {
_id: uuidV4(),
requestId: options.requestId,
data: new TextDecoder('utf-8').decode(chunk),
type: 'message',
timestamp: Date.now(),
direction: 'INCOMING',
};
eventLogFileStreams.get(options.requestId)?.write(JSON.stringify(messageEvent) + '\n');
}
// NOTE: this can only happen if the stream is closed cleanly by the remote server
eventLogFileStreams.get(options.requestId)?.end();
console.log('response stream: ended');
event.sender.send(readyStateChannel, false);
});
curl.perform();
CurlConnections.set(options.requestId, curl);
} catch (e) {
console.error('unhandled error:', e);
deleteRequestMaps(request._id, e.message || 'Something went wrong');
createErrorResponse(responseId, request._id, responseEnvironmentId, timelinePath, e.message || 'Something went wrong');
}
};
const createErrorResponse = async (responseId: string, requestId: string, environmentId: string | null, timelinePath: string, message: string) => {
const settings = await models.settings.getOrCreate();
const responsePatch = {
_id: responseId,
parentId: requestId,
environmentId: environmentId,
timelinePath,
statusMessage: 'Error',
error: message,
};
const res = await models.response.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
};
const deleteRequestMaps = async (requestId: string, message: string, event?: CurlCloseEvent | CurlErrorEvent) => {
if (event) {
eventLogFileStreams.get(requestId)?.write(JSON.stringify(event) + '\n');
}
eventLogFileStreams.get(requestId)?.end();
eventLogFileStreams.delete(requestId);
timelineFileStreams.get(requestId)?.write(JSON.stringify({ value: message, name: 'Text', timestamp: Date.now() }) + '\n');
timelineFileStreams.get(requestId)?.end();
timelineFileStreams.delete(requestId);
CurlConnections.delete(requestId);
};
const getCurlReadyState = async (
options: { requestId: string }
): Promise<CurlConnection['isOpen']> => {
return CurlConnections.get(options.requestId)?.isOpen ?? false;
};
const closeCurlConnection = (
event: Electron.IpcMainInvokeEvent,
options: { requestId: string }
): void => {
if (!CurlConnections.get(options.requestId)) {
return;
}
const readyStateChannel = `curl.${options.requestId}.readyState`;
const statusCode = +(CurlConnections.get(options.requestId)?.getInfo(Curl.info.HTTP_CONNECTCODE) || 0);
const closeEvent: CurlCloseEvent = {
_id: uuidV4(),
requestId: options.requestId,
type: 'close',
timestamp: Date.now(),
statusCode,
reason: '',
code: 0,
wasClean: true,
};
CurlConnections.get(options.requestId)?.close();
deleteRequestMaps(options.requestId, 'Closing connection', closeEvent);
event.sender.send(readyStateChannel, false);
};
const closeAllCurlConnections = (): void => CurlConnections.forEach(curl => curl.close());
const findMany = async (
options: { responseId: string }
): Promise<CurlEvent[]> => {
const response = await models.response.getById(options.responseId);
if (!response || !response.bodyPath) {
return [];
}
const body = await fs.promises.readFile(response.bodyPath);
return body.toString().split('\n').filter(e => e?.trim())
// Parse the message
.map(e => JSON.parse(e))
// Reverse the list of messages so that we get the latest message first
.reverse() || [];
};
export interface CurlBridgeAPI {
open: (options: OpenCurlRequestOptions) => void;
close: (options: { requestId: string }) => void;
closeAll: typeof closeAllCurlConnections;
readyState: {
getCurrent: typeof getCurlReadyState;
};
event: {
findMany: typeof findMany;
};
}
export const registerCurlHandlers = () => {
ipcMain.handle('curl.open', openCurlConnection);
ipcMain.on('curl.close', closeCurlConnection);
ipcMain.on('curl.closeAll', closeAllCurlConnections);
ipcMain.handle('curl.readyState', (_, options: Parameters<typeof getCurlReadyState>[0]) => getCurlReadyState(options));
ipcMain.handle('curl.event.findMany', (_, options: Parameters<typeof findMany>[0]) => findMany(options));
};
electron.app.on('window-all-closed', closeAllCurlConnections);

View File

@ -98,11 +98,172 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise<CurlRequ
fs.mkdirSync(responsesDir, { recursive: true });
const responseBodyPath = path.join(responsesDir, uuidv4() + '.response');
const debugTimeline: ResponseTimelineEntry[] = [];
const { requestId, req, finalUrl, settings, certificates, caCertficatePath, socketPath, authHeader } = options;
const curl = new Curl();
const caCert = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n');
const { curl, debugTimeline } = createConfiguredCurlInstance({
req,
finalUrl,
settings,
caCert,
certificates,
socketPath,
});
const { method, body } = req;
// Only set CURLOPT_CUSTOMREQUEST if not HEAD or GET.
// See https://curl.haxx.se/libcurl/c/CURLOPT_CUSTOMREQUEST.html
// This is how you tell Curl to send a HEAD request
if (method.toUpperCase() === 'HEAD') {
curl.setOpt(Curl.option.NOBODY, 1);
} else if (method.toUpperCase() === 'POST') { // This is how you tell Curl to send a POST request
curl.setOpt(Curl.option.POST, 1);
} else { // IMPORTANT: Only use CUSTOMREQUEST for all but HEAD and POST
curl.setOpt(Curl.option.CUSTOMREQUEST, method);
}
const requestBody = parseRequestBody({ body, method });
const requestBodyPath = await parseRequestBodyPath(body);
const isMultipart = body.mimeType === CONTENT_TYPE_FORM_DATA && requestBodyPath;
let requestFileDescriptor: number | undefined;
const { authentication } = req;
if (requestBodyPath) {
// AWS IAM file upload not supported
invariant(authentication.type !== AUTH_AWS_IAM, 'AWS authentication not supported for provided body type');
const { size: contentLength } = fs.statSync(requestBodyPath);
curl.setOpt(Curl.option.INFILESIZE_LARGE, contentLength);
curl.setOpt(Curl.option.UPLOAD, 1);
// We need this, otherwise curl will send it as a POST
curl.setOpt(Curl.option.CUSTOMREQUEST, method);
// read file into request and close file descriptor
requestFileDescriptor = fs.openSync(requestBodyPath, 'r');
curl.setOpt(Curl.option.READDATA, requestFileDescriptor);
curl.on('end', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath));
curl.on('error', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath));
} else if (requestBody !== undefined) {
curl.setOpt(Curl.option.POSTFIELDS, requestBody);
}
const headerStrings = parseHeaderStrings({ req, requestBody, requestBodyPath, finalUrl, authHeader });
curl.setOpt(Curl.option.HTTPHEADER, headerStrings);
// Create instance and handlers, poke value options in, set up write and debug callbacks, listen for events
const responseBodyWriteStream = fs.createWriteStream(responseBodyPath);
// cancel request by id map
cancelCurlRequestHandlers[requestId] = () => {
if (requestFileDescriptor && responseBodyPath) {
closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath);
}
curl.close();
};
// set up response writer
let responseBodyBytes = 0;
curl.setOpt(Curl.option.WRITEFUNCTION, buffer => {
console.log('write', buffer.length);
responseBodyBytes += buffer.length;
responseBodyWriteStream.write(buffer);
return buffer.length;
});
// set up response logger
curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => {
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;
}
// NOTE: resolves "Text" from CurlInfoDebug[CurlInfoDebug.Text]
let name = CurlInfoDebug[infoType] as keyof typeof CurlInfoDebug;
let timelineMessage;
const isRequestData = infoType === CurlInfoDebug.DataOut;
if (isRequestData) {
// Ignore large post data messages
const isLessThan10KB = buffer.length / 1024 < (settings.maxTimelineDataSizeKB || 1);
timelineMessage = isLessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`;
}
const isResponseData = infoType === CurlInfoDebug.DataIn;
if (isResponseData) {
timelineMessage = `Received ${describeByteSize(buffer.length)} chunk`;
name = 'Text';
}
const value = timelineMessage || buffer.toString('utf8');
debugTimeline.push({ name, value, timestamp: Date.now() });
return 0;
});
// returns "rawHeaders" string in a buffer, rather than HeaderInfo[] type which is an object with deduped keys
// this provides support for multiple set-cookies and duplicated headers
curl.enable(CurlFeature.Raw);
// NOTE: legacy write end callback
curl.on('end', () => responseBodyWriteStream.end());
curl.on('end', async (_1: any, _2: any, 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, responseBodyPath });
});
// NOTE: legacy write end callback
curl.on('error', () => responseBodyWriteStream.end());
curl.on('error', async (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: 0, reason: '', headers: [] }] });
});
curl.perform();
} catch (error) {
console.error(error);
const patch = {
statusMessage: 'Error',
error: error.message || 'Something went wrong',
elapsedTime: 0,
};
resolve({ patch, debugTimeline: [], headerResults: [{ version: '', code: 0, reason: '', headers: [] }] });
}
});
export const createConfiguredCurlInstance = ({
req,
finalUrl,
settings,
caCert,
certificates,
socketPath,
}: {
req: RequestUsedHere;
finalUrl: string;
settings: SettingsUsedHere;
certificates: ClientCertificate[];
caCert: string;
socketPath?: string;
}) => {
const debugTimeline: ResponseTimelineEntry[] = [];
const curl = new Curl();
curl.setOpt(Curl.option.URL, finalUrl);
socketPath && curl.setOpt(Curl.option.UNIX_SOCKET_PATH, socketPath);
@ -110,9 +271,7 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise<CurlRequ
curl.setOpt(Curl.option.NOPROGRESS, true); // True so debug function works
curl.setOpt(Curl.option.ACCEPT_ENCODING, ''); // True so curl doesn't print progress
// attempt to read CA Certificate PEM from disk, fallback to root certificates
const caCert = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n');
curl.setOpt(Curl.option.CAINFO_BLOB, caCert);
certificates.forEach(validCert => {
const { passphrase, cert, key, pfx } = validCert;
if (cert) {
@ -213,47 +372,10 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise<CurlRequ
}
}
}
const { method, body } = req;
// Only set CURLOPT_CUSTOMREQUEST if not HEAD or GET.
// See https://curl.haxx.se/libcurl/c/CURLOPT_CUSTOMREQUEST.html
// This is how you tell Curl to send a HEAD request
if (method.toUpperCase() === 'HEAD') {
curl.setOpt(Curl.option.NOBODY, 1);
} else if (method.toUpperCase() === 'POST') { // This is how you tell Curl to send a POST request
curl.setOpt(Curl.option.POST, 1);
} else { // IMPORTANT: Only use CUSTOMREQUEST for all but HEAD and POST
curl.setOpt(Curl.option.CUSTOMREQUEST, method);
}
const requestBody = parseRequestBody({ body, method });
const requestBodyPath = await parseRequestBodyPath(body);
const isMultipart = body.mimeType === CONTENT_TYPE_FORM_DATA && requestBodyPath;
let requestFileDescriptor: number;
const { authentication } = req;
if (requestBodyPath) {
// AWS IAM file upload not supported
invariant(authentication.type !== AUTH_AWS_IAM, 'AWS authentication not supported for provided body type');
const { size: contentLength } = fs.statSync(requestBodyPath);
curl.setOpt(Curl.option.INFILESIZE_LARGE, contentLength);
curl.setOpt(Curl.option.UPLOAD, 1);
// We need this, otherwise curl will send it as a POST
curl.setOpt(Curl.option.CUSTOMREQUEST, method);
// read file into request and close file descriptor
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));
} else if (requestBody !== undefined) {
curl.setOpt(Curl.option.POSTFIELDS, requestBody);
}
// suppress node-libcurl default user-agent
curl.setOpt(Curl.option.USERAGENT, '');
const headerStrings = parseHeaderStrings({ req, requestBody, requestBodyPath, finalUrl, authHeader });
curl.setOpt(Curl.option.HTTPHEADER, headerStrings);
const { headers } = req;
const { headers, authentication } = req;
const { username, password, disabled } = authentication;
const isDigest = authentication.type === AUTH_DIGEST;
const isNLTM = authentication.type === AUTH_NTLM;
@ -267,108 +389,13 @@ export const curlRequest = (options: CurlRequestOptions) => new Promise<CurlRequ
if (authentication.type === AUTH_NETRC) {
curl.setOpt(Curl.option.NETRC, CurlNetrc.Required);
}
// Create instance and handlers, poke value options in, set up write and debug callbacks, listen for events
const responseBodyWriteStream = fs.createWriteStream(responseBodyPath);
// cancel request by id map
cancelCurlRequestHandlers[requestId] = () => {
if (requestFileDescriptor && responseBodyPath) {
closeReadFunction(requestFileDescriptor, isMultipart, requestBodyPath);
}
curl.close();
return { curl, debugTimeline };
};
// 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
curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => {
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;
}
// NOTE: resolves "Text" from CurlInfoDebug[CurlInfoDebug.Text]
let name = CurlInfoDebug[infoType] as keyof typeof CurlInfoDebug;
let timelineMessage;
const isRequestData = infoType === CurlInfoDebug.DataOut;
if (isRequestData) {
// Ignore large post data messages
const isLessThan10KB = buffer.length / 1024 < (settings.maxTimelineDataSizeKB || 1);
timelineMessage = isLessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`;
}
const isResponseData = infoType === CurlInfoDebug.DataIn;
if (isResponseData) {
timelineMessage = `Received ${describeByteSize(buffer.length)} chunk`;
name = 'Text';
}
const value = timelineMessage || buffer.toString('utf8');
debugTimeline.push({ name, value, timestamp: Date.now() });
return 0;
});
// returns "rawHeaders" string in a buffer, rather than HeaderInfo[] type which is an object with deduped keys
// this provides support for multiple set-cookies and duplicated headers
curl.enable(CurlFeature.Raw);
// NOTE: legacy write end callback
curl.on('end', () => responseBodyWriteStream.end());
curl.on('end', async (_1: any, _2: any, 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, responseBodyPath });
});
// NOTE: legacy write end callback
curl.on('error', () => responseBodyWriteStream.end());
curl.on('error', async (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: 0, reason: '', headers: [] }] });
});
curl.perform();
} catch (error) {
console.error(error);
const patch = {
statusMessage: 'Error',
error: error.message || 'Something went wrong',
elapsedTime: 0,
};
resolve({ patch, debugTimeline: [], headerResults: [{ version: '', code: 0, reason: '', headers: [] }] });
}
});
const closeReadFunction = (fd: number, isMultipart: boolean, path?: string) => {
const closeReadFunction = (isMultipart: boolean, fd?: number, path?: string) => {
if (fd) {
fs.closeSync(fd);
}
// NOTE: multipart files are combined before sending, so this file is deleted after
// alt implementation to send one part at a time https://github.com/JCMais/node-libcurl/blob/develop/examples/04-multi.js
if (isMultipart && path) {

View File

@ -136,6 +136,9 @@ const openWebSocketConnection = async (
const caCertificate = (caCertficatePath && (await fs.promises.readFile(caCertficatePath)).toString()) || tls.rootCertificates.join('\n');
try {
if (!options.url) {
throw new Error('URL is required');
}
const readyStateChannel = `webSocket.${request._id}.readyState`;
const reduceArrayToLowerCaseKeyedDictionary = (acc: { [key: string]: string }, { name, value }: BaseWebSocketRequest['headers'][0]) =>
@ -249,8 +252,8 @@ const openWebSocketConnection = async (
};
const settings = await models.settings.getOrCreate();
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null });
const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id });
if (request.settingStoreCookies) {
const setCookieStrings: string[] = getSetCookieHeaders(responseHeaders).map(h => h.value);
@ -293,8 +296,8 @@ const openWebSocketConnection = async (
settingStoreCookies: request.settingStoreCookies,
};
const settings = await models.settings.getOrCreate();
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: null });
const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(request._id, { activeResponseId: res._id });
deleteRequestMaps(request._id, `Unexpected response ${incomingMessage.statusCode}`);
});
@ -380,8 +383,8 @@ const createErrorResponse = async (responseId: string, requestId: string, enviro
statusMessage: 'Error',
error: message,
};
models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: null });
const res = await models.webSocketResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
};
const deleteRequestMaps = async (requestId: string, message: string, event?: WebSocketCloseEvent | WebSocketErrorEvent) => {

View File

@ -115,6 +115,10 @@ export const isRequest = (model: Pick<BaseModel, 'type'>): model is Request => (
model.type === type
);
export const isEventStreamRequest = (model: Pick<BaseModel, 'type'>) => (
isRequest(model) && model.headers?.find(h => h.name === 'Accept')?.value === 'text/event-stream'
);
export function init(): BaseRequest {
return {
url: '',

View File

@ -250,6 +250,7 @@ export function getTimeline(response: Response, showBody?: boolean) {
const rawBuffer = fs.readFileSync(timelinePath);
const timelineString = rawBuffer.toString();
const timeline = JSON.parse(timelineString) as ResponseTimelineEntry[];
const body: ResponseTimelineEntry[] = showBody ? [
{
name: 'DataOut',

View File

@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron';
import { gRPCBridgeAPI } from './main/ipc/grpc';
import { CurlBridgeAPI } from './main/network/curl';
import type { WebSocketBridgeAPI } from './main/network/websocket';
const webSocket: WebSocketBridgeAPI = {
@ -15,6 +16,18 @@ const webSocket: WebSocketBridgeAPI = {
send: options => ipcRenderer.invoke('webSocket.event.send', options),
},
};
const curl: CurlBridgeAPI = {
open: options => ipcRenderer.invoke('curl.open', options),
close: options => ipcRenderer.send('curl.close', options),
closeAll: () => ipcRenderer.send('curl.closeAll'),
readyState: {
getCurrent: options => ipcRenderer.invoke('curl.readyState', options),
},
event: {
findMany: options => ipcRenderer.invoke('curl.event.findMany', options),
},
};
const grpc: gRPCBridgeAPI = {
start: options => ipcRenderer.send('grpc.start', options),
sendMessage: options => ipcRenderer.send('grpc.sendMessage', options),
@ -44,6 +57,7 @@ const main: Window['main'] = {
},
webSocket,
grpc,
curl,
trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options),
trackPageView: options => ipcRenderer.send('trackPageView', options),
axiosRequest: options => ipcRenderer.invoke('axiosRequest', options),

View File

@ -95,6 +95,7 @@ export interface CodeEditorProps {
onBlur?: () => void;
onChange?: (value: string) => void;
onClickLink?: CodeMirrorLinkClickCallback;
pinToBottom?: boolean;
placeholder?: string;
readOnly?: boolean;
style?: Object;
@ -159,6 +160,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({
onBlur,
onChange,
onClickLink,
pinToBottom,
placeholder,
readOnly,
style,
@ -320,6 +322,19 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({
// Don't allow non-breaking spaces because they break the GraphQL syntax
change.update?.(change.from, change.to, change.text.map(normalizeIrregularWhitespace));
}
if (pinToBottom) {
const scrollInfo = doc.getScrollInfo();
const scrollPosition = scrollInfo.height - scrollInfo.clientHeight;
doc.scrollTo(0, scrollPosition);
}
});
codeMirror.current.on('change', (doc: CodeMirror.Editor) => {
if (pinToBottom) {
const scrollInfo = doc.getScrollInfo();
const scrollPosition = scrollInfo.height - scrollInfo.clientHeight;
doc.scrollTo(0, scrollPosition);
}
});
codeMirror.current.on('keydown', (_: CodeMirror.Editor, event: KeyboardEvent) => {

View File

@ -12,9 +12,10 @@ import {
import { documentationLinks } from '../../../../common/documentation';
import { getContentTypeHeader } from '../../../../common/misc';
import * as models from '../../../../models';
import type {
Request,
RequestBodyParameter,
import {
isEventStreamRequest,
type Request,
type RequestBodyParameter,
} from '../../../../models/request';
import type { Workspace } from '../../../../models/workspace';
import { NunjucksEnabledProvider } from '../../../context/nunjucks/nunjucks-enabled-context';
@ -135,9 +136,14 @@ export const BodyEditor: FC<Props> = ({
} else if (!isBodyEmpty) {
const contentType = getContentTypeFromHeaders(request.headers) || mimeType;
return <RawEditor uniquenessKey={uniqueKey} contentType={contentType || 'text/plain'} content={request.body.text || ''} onChange={handleRawChange} />;
} else {
return <EmptyStatePane icon={<SvgIcon icon="bug" />} documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" />;
} else if (isEventStreamRequest(request)) {
return <EmptyStatePane
icon={<i className="fa fa-paper-plane" />}
documentationLinks={[]}
title="Enter a URL and connect to start receiving event stream data"
/>;
}
return <EmptyStatePane icon={<SvgIcon icon="bug" />} documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" />;
};
return <NunjucksEnabledProvider disable={noRender}>{_render()}</NunjucksEnabledProvider>;

View File

@ -78,7 +78,7 @@ const LinkIcon = styled(SvgIcon)({
export const EmptyStatePane: FC<{
icon: ReactNode;
title: string;
secondaryAction: ReactNode;
secondaryAction?: ReactNode;
documentationLinks: {
title: string;
url: string;
@ -88,14 +88,15 @@ export const EmptyStatePane: FC<{
title,
secondaryAction,
documentationLinks,
}) => {
return (
<Panel>
}) => (<Panel>
<Wrapper>
<Icon>{icon}</Icon>
<Title>{title}</Title>
{Boolean(secondaryAction) &&
(<>
<Divider />
<SecondaryAction>{secondaryAction}</SecondaryAction>
</>)}
<DocumentationLinks>
{documentationLinks.map(({ title, url }) => (
<StyledLink key={title} href={url}>
@ -105,6 +106,4 @@ export const EmptyStatePane: FC<{
))}
</DocumentationLinks>
</Wrapper>
</Panel>
);
};
</Panel>);

View File

@ -9,15 +9,19 @@ import styled from 'styled-components';
import { database } from '../../common/database';
import { getContentDispositionHeader } from '../../common/misc';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../common/render';
import * as models from '../../models';
import { update } from '../../models/helpers/request-operations';
import { isRequest, Request } from '../../models/request';
import { isEventStreamRequest, isRequest, Request } from '../../models/request';
import * as network from '../../network/network';
import { convert } from '../../utils/importers/convert';
import { invariant } from '../../utils/invariant';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring';
import { SegmentEvent } from '../analytics';
import { updateRequestMetaByParentId } from '../hooks/create-request';
import { useCurlReadyState } from '../hooks/use-ready-state';
import { useTimeoutWhen } from '../hooks/useTimeoutWhen';
import { selectActiveEnvironment, selectActiveRequest, selectHotKeyRegistry, selectResponseDownloadPath, selectSettings } from '../redux/selectors';
import { selectActiveEnvironment, selectActiveRequest, selectActiveWorkspace, selectHotKeyRegistry, selectResponseDownloadPath, selectSettings } from '../redux/selectors';
import { Dropdown, DropdownButton, type DropdownHandle, DropdownItem, DropdownSection, ItemContent } from './base/dropdown';
import { OneLineEditor, OneLineEditorHandle } from './codemirror/one-line-editor';
import { MethodDropdown } from './dropdowns/method-dropdown';
@ -59,6 +63,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
const downloadPath = useSelector(selectResponseDownloadPath);
const hotKeyRegistry = useSelector(selectHotKeyRegistry);
const activeEnvironment = useSelector(selectActiveEnvironment);
const activeWorkspace = useSelector(selectActiveWorkspace);
const activeRequest = useSelector(selectActiveRequest);
const settings = useSelector(selectSettings);
const methodDropdownRef = useRef<DropdownHandle>(null);
@ -232,14 +237,45 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
setCurrentTimeout(undefined);
if (downloadPath) {
sendThenSetFilePath(downloadPath);
} else {
handleSend();
return;
}
}, [downloadPath, handleSend, sendThenSetFilePath]);
if (isEventStreamRequest(request)) {
const startListening = async () => {
invariant(activeWorkspace, 'activeWorkspace not found (remove with redux)');
const environmentId = activeEnvironment?._id;
const workspaceId = activeWorkspace._id;
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
// Render any nunjucks tags in the url/headers/authentication settings/cookies
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await render({
url: request.url,
headers: request.headers,
authentication: request.authentication,
parameters: request.parameters.filter(p => !p.disabled),
workspaceCookieJar,
}, renderContext);
window.main.curl.open({
requestId: request._id,
workspaceId,
url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)),
headers: rendered.headers,
authentication: rendered.authentication,
cookieJar: rendered.workspaceCookieJar,
});
};
startListening();
return;
}
handleSend();
}, [activeEnvironment?._id, activeWorkspace, downloadPath, handleSend, request, sendThenSetFilePath]);
useInterval(send, currentInterval ? currentInterval : null);
useTimeoutWhen(send, currentTimeout, !!currentTimeout);
const handleStop = () => {
if (isEventStreamRequest(request)) {
window.main.curl.close({ requestId: request._id });
return;
}
setCurrentInterval(null);
setCurrentTimeout(undefined);
};
@ -318,10 +354,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
body: r.body,
authentication: r.authentication,
parameters: r.parameters,
},
// Pass true to indicate that this is an import
true
);
}, true); // Pass true to indicate that this is an import
}
} catch (error) {
// Import failed, that's alright
@ -357,9 +390,10 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
const handleSendDropdownHide = useCallback(() => {
buttonRef.current?.blur();
}, []);
const buttonText = isEventStreamRequest(request) ? 'Connect' : (downloadPath ? 'Download' : 'Send');
const { url, method } = request;
const isCancellable = currentInterval || currentTimeout;
const isEventStreamOpen = useCurlReadyState(request._id);
const isCancellable = currentInterval || currentTimeout || isEventStreamOpen;
return (
<div className="urlbar">
<MethodDropdown
@ -385,10 +419,10 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
{isCancellable ? (
<button
type="button"
className="urlbar__send-btn danger"
className="urlbar__send-btn"
onClick={handleStop}
>
Cancel
{isEventStreamRequest(request) ? 'Disconnect' : 'Cancel'}
</button>
) : (
<>
@ -397,9 +431,8 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
className="urlbar__send-btn"
onClick={send}
>
{downloadPath ? 'Download' : 'Send'}
</button>
<Dropdown
{buttonText}</button>
{isEventStreamRequest(request) ? null : (<Dropdown
key="dropdown"
className="tall"
ref={dropdownRef}
@ -478,7 +511,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
/>
</DropdownItem>
</DropdownSection>
</Dropdown>
</Dropdown>)}
</>
)}
</div>

View File

@ -44,7 +44,6 @@ export const SidebarCreateDropdown = () => {
>
<DropdownItem aria-label='HTTP Request'>
<ItemContent
// dataTestId='CreateHttpRequest'
icon="plus-circle"
label="HTTP Request"
hint={hotKeyRegistry.request_createHTTP}
@ -52,6 +51,14 @@ export const SidebarCreateDropdown = () => {
/>
</DropdownItem>
<DropdownItem aria-label='Event Stream Request'>
<ItemContent
icon="plus-circle"
label="Event Stream Request"
onClick={() => create('Event Stream')}
/>
</DropdownItem>
<DropdownItem aria-label='GraphQL Request'>
<ItemContent
icon="plus-circle"

View File

@ -8,12 +8,12 @@ import { getMethodOverrideHeader } from '../../../common/misc';
import { stats, workspaceMeta } from '../../../models';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import * as requestOperations from '../../../models/helpers/request-operations';
import { isRequest, Request } from '../../../models/request';
import { isEventStreamRequest, isRequest, Request } from '../../../models/request';
import { RequestGroup } from '../../../models/request-group';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { useNunjucks } from '../../context/nunjucks/use-nunjucks';
import { ReadyState, useWSReadyState } from '../../context/websocket-client/use-ws-ready-state';
import { createRequest, updateRequestMetaByParentId } from '../../hooks/create-request';
import { ReadyState, useCurlReadyState, useWSReadyState } from '../../hooks/use-ready-state';
import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceMeta } from '../../redux/selectors';
import type { DropdownHandle } from '../base/dropdown';
import { Editable } from '../base/editable';
@ -242,6 +242,10 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
methodTag = <GrpcTag />;
} else if (isWebSocketRequest(request)) {
methodTag = <WebSocketTag />;
} else if (isEventStreamRequest(request)) {
methodTag = (<div className="tag tag--no-bg tag--small">
<span className="tag__inner" style={{ color: 'var(--color-info)' }}>SSE</span>
</div>);
} else if (isRequest(request)) {
methodTag = <MethodTag method={request.method} override={methodOverrideValue} />;
}
@ -276,9 +280,10 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
/>
)}
/>
{isWebSocketRequest(request) && (
{isWebSocketRequest(request) ?
<WebSocketSpinner requestId={request._id} />
)}
: <EventStreamSpinner requestId={request._id} />
}
</div>
</button>
<div className="sidebar__actions">
@ -331,3 +336,8 @@ const WebSocketSpinner = ({ requestId }: { requestId: string }) => {
const readyState = useWSReadyState(requestId);
return readyState === ReadyState.OPEN ? <ConnectionCircle data-testid="WebSocketSpinner__Connected" /> : null;
};
const EventStreamSpinner = ({ requestId }: { requestId: string }) => {
const readyState = useCurlReadyState(requestId);
return readyState ? <ConnectionCircle data-testid="EventStreamSpinner__Connected" /> : null;
};

View File

@ -5,9 +5,10 @@ import { CodeEditor, CodeEditorHandle } from '../codemirror/code-editor';
interface Props {
timeline: ResponseTimelineEntry[];
pinToBottom?: boolean;
}
export const ResponseTimelineViewer: FC<Props> = ({ timeline }) => {
export const ResponseTimelineViewer: FC<Props> = ({ timeline, pinToBottom }) => {
const editorRef = useRef<CodeEditorHandle>(null);
const rows = timeline
.map(({ name, value }, i, all) => {
@ -49,6 +50,7 @@ export const ResponseTimelineViewer: FC<Props> = ({ timeline }) => {
defaultValue={rows}
className="pad-left"
mode="curl"
pinToBottom={pinToBottom}
/>
);
};

View File

@ -254,8 +254,7 @@ export const ResponseViewer = ({
body={getBodyAsString()}
key={disableHtmlPreviewJs ? 'no-js' : 'yes-js'}
url={url}
webpreferences={`disableDialogs=true, javascript=${disableHtmlPreviewJs ? 'no' : 'yes'
}`}
webpreferences={`disableDialogs=true, javascript=${disableHtmlPreviewJs ? 'no' : 'yes'}`}
/>
);
}

View File

@ -5,7 +5,7 @@ import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/r
import * as models from '../../../models';
import { WebSocketRequest } from '../../../models/websocket-request';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring';
import { ReadyState } from '../../context/websocket-client/use-ws-ready-state';
import { ReadyState } from '../../hooks/use-ready-state';
import { OneLineEditor, OneLineEditorHandle } from '../codemirror/one-line-editor';
import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder';
import { showAlert, showModal } from '../modals';

View File

@ -4,6 +4,7 @@ import React, { FC, useRef } from 'react';
import { useMeasure } from 'react-use';
import styled from 'styled-components';
import { CurlEvent } from '../../../main/network/curl';
import { WebSocketEvent } from '../../../main/network/websocket';
import { SvgIcon, SvgIconProps } from '../svg-icon';
@ -13,9 +14,9 @@ const Timestamp: FC<{ time: Date | number }> = ({ time }) => {
};
interface Props {
events: WebSocketEvent[];
events: WebSocketEvent[] | CurlEvent[];
selectionId?: string;
onSelect: (event: WebSocketEvent) => void;
onSelect: (event: WebSocketEvent | CurlEvent) => void;
}
const Divider = styled('div')({
@ -79,7 +80,7 @@ const EventIconCell = styled('div')({
padding: 'var(--padding-xs)',
});
function getIcon(event: WebSocketEvent): SvgIconProps['icon'] {
function getIcon(event: WebSocketEvent | CurlEvent): SvgIconProps['icon'] {
switch (event.type) {
case 'message': {
if (event.direction === 'OUTGOING') {
@ -111,7 +112,7 @@ const EventMessageCell = styled('div')({
padding: 'var(--padding-xs)',
});
const getMessage = (event: WebSocketEvent): string => {
const getMessage = (event: WebSocketEvent | CurlEvent): string => {
switch (event.type) {
case 'message': {
if ('data' in event && typeof event.data === 'object') {

View File

@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW, PREVIEW_MODE_SOURCE, PreviewMode } from '../../../common/constants';
import { CurlEvent, CurlMessageEvent } from '../../../main/network/curl';
import { WebSocketEvent, WebSocketMessageEvent } from '../../../main/network/websocket';
import { requestMeta } from '../../../models';
import { selectResponsePreviewMode } from '../../redux/selectors';
@ -36,7 +37,7 @@ const PreviewPaneContents = styled.div({
flexGrow: 1,
});
export const MessageEventView: FC<Props<WebSocketMessageEvent>> = ({ event, requestId }) => {
export const MessageEventView: FC<Props<CurlMessageEvent | WebSocketMessageEvent>> = ({ event, requestId }) => {
let raw = event.data.toString();
// Best effort to parse the binary data as a string
@ -136,7 +137,7 @@ export const MessageEventView: FC<Props<WebSocketMessageEvent>> = ({ event, requ
);
};
export const EventView: FC<Props<WebSocketEvent>> = ({ event, ...props }) => {
export const EventView: FC<Props<CurlEvent | WebSocketEvent>> = ({ event, ...props }) => {
switch (event.type) {
case 'message':
return <MessageEventView event={event} {...props} />;

View File

@ -4,16 +4,18 @@ import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getSetCookieHeaders } from '../../../common/misc';
import { CurlEvent } from '../../../main/network/curl';
import { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import { WebSocketEvent } from '../../../main/network/websocket';
import { Response } from '../../../models/response';
import { WebSocketResponse } from '../../../models/websocket-response';
import { useWebSocketConnectionEvents } from '../../context/websocket-client/use-ws-connection-events';
import { useRealtimeConnectionEvents } from '../../hooks/use-realtime-connection-events';
import { selectActiveResponse } from '../../redux/selectors';
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown';
import { ErrorBoundary } from '../error-boundary';
import { EmptyStatePane } from '../panes/empty-state-pane';
import { Pane, PaneHeader as OriginalPaneHeader } from '../panes/pane';
import { PlaceholderResponsePane } from '../panes/placeholder-response-pane';
import { SvgIcon } from '../svg-icon';
import { SizeTag } from '../tags/size-tag';
import { StatusTag } from '../tags/status-tag';
@ -82,46 +84,36 @@ const PaddedButton = styled('button')({
padding: 'var(--padding-sm)',
});
export const WebSocketResponsePane: FC<{ requestId: string }> =
export const RealtimeResponsePane: FC<{ requestId: string }> =
({
requestId,
}) => {
const response = useSelector(selectActiveResponse) as WebSocketResponse | null;
const response = useSelector(selectActiveResponse) as WebSocketResponse | Response | null;
if (!response) {
return (
<Pane type="response">
<PaneHeader />
<EmptyStatePane
icon={<i className="fa fa-paper-plane" />}
documentationLinks={[
{
title: 'Introduction to WebSockets in Insomnia',
url: 'https://docs.insomnia.rest/insomnia/requests',
},
]}
title="Enter a URL and connect to a WebSocket server to start sending data"
secondaryAction="Select a payload type to send data to the connection"
/>
<PlaceholderResponsePane />
</Pane>
);
}
return <WebSocketActiveResponsePane requestId={requestId} response={response} />;
return <RealtimeActiveResponsePane requestId={requestId} response={response} />;
};
const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketResponse }> = ({
const RealtimeActiveResponsePane: FC<{ requestId: string; response: WebSocketResponse | Response }> = ({
requestId,
response,
}) => {
const [selectedEvent, setSelectedEvent] = useState<WebSocketEvent | null>(null);
const [selectedEvent, setSelectedEvent] = useState<CurlEvent | WebSocketEvent | null>(null);
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
const [clearEventsBefore, setClearEventsBefore] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [eventType, setEventType] = useState<WebSocketEvent['type']>();
const allEvents = useWebSocketConnectionEvents({ responseId: response._id });
const handleSelection = (event: WebSocketEvent) => {
setSelectedEvent((selected: WebSocketEvent | null) => selected?._id === event._id ? null : event);
const [eventType, setEventType] = useState<CurlEvent['type']>();
const protocol = response.type === 'WebSocketResponse' ? 'webSocket' : 'curl';
const allEvents = useRealtimeConnectionEvents({ responseId: response._id, protocol });
const handleSelection = (event: CurlEvent | WebSocketEvent) => {
setSelectedEvent((selected: CurlEvent | WebSocketEvent | null) => selected?._id === event._id ? null : event);
};
const events = allEvents.filter(event => {
@ -163,12 +155,12 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe
useEffect(() => {
let isMounted = true;
const fn = async () => {
// @TODO: this needs to fs.watch or tail the file, instead of reading the whole thing on every event.
// or alternatively a throttle to keep it from reading too frequently
const rawBuffer = await fs.promises.readFile(response.timelinePath);
const timelineString = rawBuffer.toString();
const timelineParsed = timelineString.split('\n').filter(e => e?.trim()).map(e => JSON.parse(e));
isMounted && setTimeline(timelineParsed);
if (isMounted) {
setTimeline(timelineParsed);
}
};
fn();
return () => {
@ -191,7 +183,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe
className="tall pane__header__right"
/>
</PaneHeader>
<Tabs aria-label="Websocket response pane tabs">
<Tabs aria-label="Curl response pane tabs">
<TabItem key="events" title="Events">
<PaneBodyContent>
{response.error ? <ResponseErrorViewer url={response.url} error={response.error} />
@ -205,7 +197,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe
gap: 'var(--padding-sm)',
}}
>
<select onChange={e => setEventType(e.currentTarget.value as WebSocketEvent['type'])}>
<select onChange={e => setEventType(e.currentTarget.value as CurlEvent['type'])}>
<option value="">All</option>
<option value="message">Message</option>
<option value="open">Open</option>
@ -304,6 +296,7 @@ const WebSocketActiveResponsePane: FC<{ requestId: string; response: WebSocketRe
<ResponseTimelineViewer
key={response._id}
timeline={timeline}
pinToBottom={true}
/>
</TabItem>
</Tabs>

View File

@ -8,7 +8,7 @@ import * as models from '../../../models';
import { Environment } from '../../../models/environment';
import { WebSocketRequest } from '../../../models/websocket-request';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring';
import { ReadyState, useWSReadyState } from '../../context/websocket-client/use-ws-ready-state';
import { ReadyState, useWSReadyState } from '../../hooks/use-ready-state';
import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version';
import { selectActiveRequestMeta, selectSettings } from '../../redux/selectors';
import { TabItem, Tabs } from '../base/tabs';
@ -36,8 +36,7 @@ const SendMessageForm = styled.form({
position: 'relative',
boxSizing: 'border-box',
});
const SendButton = styled.button<{ isConnected: boolean }>(({ isConnected }) =>
({
const SendButton = styled.button<{ isConnected: boolean }>(({ isConnected }) => ({
padding: '0 var(--padding-md)',
marginLeft: 'var(--padding-xs)',
height: '100%',

View File

@ -1,4 +1,5 @@
import {
CONTENT_TYPE_EVENT_STREAM,
CONTENT_TYPE_GRAPHQL,
CONTENT_TYPE_JSON,
METHOD_GET,
@ -46,7 +47,7 @@ export const setActiveRequest = async (
});
};
export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL' | 'WebSocket';
export type CreateRequestType = 'HTTP' | 'gRPC' | 'GraphQL' | 'WebSocket' | 'Event Stream';
type RequestCreator = (input: {
parentId: string;
requestType: CreateRequestType;
@ -89,6 +90,24 @@ export const createRequest: RequestCreator = async ({
break;
}
case 'Event Stream': {
const request = await models.request.create({
parentId,
method: METHOD_GET,
url: 'http://localhost:4010/events',
headers: [
{
name: 'Accept',
value: CONTENT_TYPE_EVENT_STREAM,
},
],
name: 'New Event Stream',
});
models.stats.incrementCreatedRequests();
setActiveRequest(request._id, workspaceId);
break;
}
case 'HTTP': {
const request = await models.request.create({
parentId,

View File

@ -30,3 +30,28 @@ export function useWSReadyState(requestId: string): ReadyState {
return readyState;
}
export function useCurlReadyState(requestId: string): boolean {
const [readyState, setReadyState] = useState<boolean>(false);
useEffect(() => {
let isMounted = true;
window.main.curl.readyState.getCurrent({ requestId })
.then((currentReadyState: boolean) => {
isMounted && setReadyState(currentReadyState);
});
const unsubscribe = window.main.on(`curl.${requestId}.readyState`,
(_, incomingReadyState: boolean) => {
isMounted && setReadyState(incomingReadyState);
});
return () => {
isMounted = false;
unsubscribe();
};
}, [requestId]);
return readyState;
}

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useInterval } from 'react-use';
import { WebSocketEvent } from '../../../main/network/websocket';
import { CurlEvent } from '../../main/network/curl';
import { WebSocketEvent } from '../../main/network/websocket';
export function useWebSocketConnectionEvents({ responseId }: { responseId: string }) {
const [events, setEvents] = useState<WebSocketEvent[]>([]);
export function useRealtimeConnectionEvents({ responseId, protocol }: { responseId: string; protocol: 'curl' | 'webSocket' }) {
const [events, setEvents] = useState<CurlEvent[] | WebSocketEvent[]>([]);
useEffect(() => {
setEvents([]);
@ -14,7 +15,7 @@ export function useWebSocketConnectionEvents({ responseId }: { responseId: strin
() => {
let isMounted = true;
const fn = async () => {
const allEvents = await window.main.webSocket.event.findMany({ responseId });
const allEvents = await window.main[protocol].event.findMany({ responseId });
if (isMounted) {
setEvents(allEvents);
}

View File

@ -9,7 +9,7 @@ import * as models from '../../models';
import { isGrpcRequest } from '../../models/grpc-request';
import { getByParentId as getGrpcRequestMetaByParentId } from '../../models/grpc-request-meta';
import * as requestOperations from '../../models/helpers/request-operations';
import { isRequest } from '../../models/request';
import { isEventStreamRequest, isRequest } from '../../models/request';
import { getByParentId as getRequestMetaByParentId } from '../../models/request-meta';
import { isWebSocketRequest } from '../../models/websocket-request';
import { invariant } from '../../utils/invariant';
@ -34,8 +34,8 @@ import { ResponsePane } from '../components/panes/response-pane';
import { SidebarChildren } from '../components/sidebar/sidebar-children';
import { SidebarFilter } from '../components/sidebar/sidebar-filter';
import { SidebarLayout } from '../components/sidebar-layout';
import { RealtimeResponsePane } from '../components/websockets/realtime-response-pane';
import { WebSocketRequestPane } from '../components/websockets/websocket-request-pane';
import { WebSocketResponsePane } from '../components/websockets/websocket-response-pane';
import { updateRequestMetaByParentId } from '../hooks/create-request';
import { createRequestGroup } from '../hooks/create-request-group';
import {
@ -123,11 +123,13 @@ export const Debug: FC = () => {
setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, running: false } : s));
}), []);
useEffect(() => window.main.on('grpc.data', (_, id, value) => {
setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, responseMessages: [...s.responseMessages, {
setGrpcStates(state => state.map(s => s.requestId === id ? {
...s, responseMessages: [...s.responseMessages, {
id: generateId(),
text: JSON.stringify(value),
created: Date.now(),
}] } : s));
}],
} : s));
}), []);
useEffect(() => window.main.on('grpc.error', (_, id, error) => {
setGrpcStates(state => state.map(s => s.requestId === id ? { ...s, error } : s));
@ -243,7 +245,7 @@ export const Debug: FC = () => {
window.main.grpc.closeAll();
};
}, [activeEnvironment?._id]);
const isRealtimeRequest = activeRequest && (isWebSocketRequest(activeRequest) || isEventStreamRequest(activeRequest));
return (
<SidebarLayout
renderPageSidebar={activeWorkspace ? <Fragment>
@ -300,9 +302,9 @@ export const Debug: FC = () => {
<ErrorBoundary showAlert>
{activeRequest && isGrpcRequest(activeRequest) && grpcState && (
<GrpcResponsePane activeRequest={activeRequest} grpcState={grpcState} />)}
{activeRequest && isWebSocketRequest(activeRequest) && (
<WebSocketResponsePane requestId={activeRequest._id} />)}
{activeRequest && isRequest(activeRequest) && (
{isRealtimeRequest && (
<RealtimeResponsePane requestId={activeRequest._id} />)}
{activeRequest && isRequest(activeRequest) && !isRealtimeRequest && (
<ResponsePane request={activeRequest} runningRequests={runningRequests} />)}
</ErrorBoundary>}
/>