mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
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:
parent
45ee825087
commit
3cdd4c8491
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -14,6 +14,6 @@
|
||||
},
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "modificationsIfAvailable",
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
|
@ -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';
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
358
packages/insomnia/src/main/network/curl.ts
Normal file
358
packages/insomnia/src/main/network/curl.ts
Normal 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);
|
@ -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) {
|
||||
|
@ -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) => {
|
||||
|
@ -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: '',
|
||||
|
@ -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',
|
||||
|
@ -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),
|
||||
|
@ -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) => {
|
||||
|
@ -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>;
|
||||
|
@ -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>);
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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') {
|
||||
|
@ -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} />;
|
||||
|
@ -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>
|
@ -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%',
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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>}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user